diff --git a/CHANGELOG.md b/CHANGELOG.md index ead8d4713..633d42c93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,430 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +#### Decoupled test-results store, release report and badges +Test runs (full CI phases as well as partial developer runs) can now publish a JSON record +of their results to the append-only `test-results` orphan branch via +`runtests.sh --publish-results` — decoupled from the machine that produced them, so any +contributor can supply results without homelab infrastructure. `release.sh` aggregates the +records per (commit, phase) — newest run wins, only complete phase runs qualify, results +from earlier commits stay valid when only docs/tests/tooling changed since — and posts the +honest result table to the GitHub release notes, missing or broken phases included, plus +optional JaCoCo coverage (from `-Pcoverage`). The report is a *living* one: it is not frozen +at release time. The markdown section is wrapped in `` +markers, and `scripts/updateReleaseReport.sh` — called best-effort after every +`runtests.sh --publish-results` — resolves the latest (or a given `--tag`) release, replaces +that marked section in its GitHub notes with a report for the *tag's* commit, and regenerates +the `tests`/`coverage` badges into the `test-results` store branch, so both the release notes +and the README badges (now served from `.../test-results/badges/*.json` instead of `master`) +keep refreshing automatically as new results come in, without another release being cut. +This is a report, not a gate: `release.sh` never aborts on an incomplete or red matrix, it +just says so in the release notes ("Transparenz statt Türsteher"). The aggregator itself +(`scripts/test_report.py`) still exits 0/1/3 for complete-and-green / gaps-or-broken / +store-unreachable, so a future caller or CI job that *does* want to gate on the matrix can +build that policy on top without changing the tool. Coverage records themselves are produced +by whatever runs `-Pcoverage` and passes `--coverage-xml` to `runtests.sh --publish-results` +— the CI orchestrator wiring for that is a follow-up; for now it's manual runs. + +#### PoppyDB: honest capability advertisement in the hello reply (`poppyCapabilities`) +The hello reply advertises replica-set topology and logical sessions, which makes modern +drivers enable retryable writes by default — a capability PoppyDB does not have (no +`(lsid, txnNumber)` deduplication; the road to real support is specced in #293). There is no +standard hello field to say "sessions yes, retryable writes no", so the reply now carries an +explicit `poppyCapabilities` document (`retryableWrites: false`, `journal: false`, +`durability: "snapshot"`, `readConcern: "local"`, `transactions: "partial"`, +`textSearch: "simplified"`). Non-Morphium clients should connect with `retryWrites=false`; +documented in `docs/poppydb.md` together with the other honesty changes below. + +#### PoppyDB: mongodump/mongorestore work against PoppyDB (mongo-tools compatibility) +`mongorestore` against a PoppyDB used to die at the handshake, and dumps of real-world schemas +could not be loaded at all. A restore is the natural way to seed a PoppyDB from an existing +MongoDB (and a dump the natural way to persist one), so the whole tool chain was fixed +end-to-end; a full dump → restore → dump round trip including secondary indexes now passes. +Individual fixes, each observable on its own: + +- The legacy `isMaster` (OP_QUERY) reply carried the `QueryFailure` flag, making strict drivers + (mongo-tools' Go driver) treat the hello document as an error and drop the connection. + Lenient drivers (Node, morphium) ignore OP_REPLY flags, which is why this never surfaced. +- `buildInfo` now reports a `versionArray` — mongorestore refuses servers announcing fewer + than 3 version components. +- OP_MSG kind-1 document sequences (how mongo-tools ship bulk inserts; morphium clients only + ever send kind 0) are now merged into the command body per wire spec. The kind-1 *writer* + in `OpMsg.getPayload` was rewritten as well — it never emitted the section content. +- `OpMsg.parsePayload` bounds parsing by the wire-header message size instead of the buffer + length: with PoppyDB's zero-copy Netty path, a pipelining client (mongo-tools) made the + parser run into the next message's bytes. +- BSON type 0x13 (Decimal128) is now encoded and decoded (`BigDecimal`, NaN/Infinity as + `Decimal128`) — previously any document containing a `NumberDecimal` was unparsable. +- A message that fails to decode now gets an error reply instead of being silently skipped, + which left clients hanging until their timeout. + +### Changed + +#### Object mapper: type-id class resolution and no-arg-constructor lookup cached +An in-JVM mapping benchmark (POJO with a `List>>` payload, no +network) showed `ObjectMapperImpl` roundtrips at ~100µs/op — 3.3x slower than the official +driver's `PojoCodecProvider`. Profiling (JFR, 1ms sampling) put the single biggest avoidable +cost in `AnnotationAndReflectionHelper.getClassForTypeId()`, which ran +`Class.forName()` on every call — once per embedded object carrying a `class_name` +attribute, i.e. dozens of times per deserialized document. That lookup is now cached per +helper instance (typeId → Class, successful lookups only, so hot-reload scenarios get a +fresh cache with a fresh helper). In addition, `deserialize()` now caches the resolved +no-arg constructor per class (with a sentinel for classes without one, so the +exception-based probe runs once instead of per call — measured at ~0.4µs per miss), and the +hot `customMappers` checks use a single `get()` instead of `containsKey()`+`get()`. +Deserialization of the benchmark payload drops from ~57µs to ~38µs (−34%), full roundtrip +from ~100µs to ~76µs; the remaining gap to `PojoCodecProvider` (~2.5x) is structural — +per-value map lookups against per-class precompiled codecs. Behavior is unchanged. + +#### Test suite: timing-sensitive sleep+assert patterns replaced with condition waits (#292) +A `BulkInsertTest` flake on the CI matrix (count asserted immediately after `storeList`) turned +out to be one instance of a suite-wide pattern: `Thread.sleep` followed by an assertion on DB or +messaging state. The nine files with the highest density — BulkInsertTest, MorphiumTest, +MapListTest, DataTypeTests, QueryUpdateOperatorsTest, UpdateTest, CacheSyncTest and the two +(class-level disabled) ncmessaging suites — now use bounded +`TestUtils.waitForConditionToBecomeTrue` waits instead; unbounded poll loops got bounds too. +Sleeps that are load-bearing (negative "must-NOT-arrive" windows, exactly-once settle windows, +TTL waits, pause-semantics and throughput measurements) were deliberately kept. No production +code affected; the remaining sleep+assert files are tracked in #292. + +#### Test suite: retired the ncmessaging (polling-only) test package (#292) +The `ncmessaging` suites were aging copies of the regular messaging tests with +`setUseChangeStream(false)` hard-coded — mostly class-level `@Disabled` and drifting. The +polling-only mode itself stays fully supported (it is what morphium auto-selects on standalone +MongoDB, where change streams don't exist) and remains tested: the MongoDB-Single CI phase runs +the entire messaging test set in exactly that mode. The one scenario without a counterpart — +request/reply round trips forced to polling on a replica set — moved to +`AnsweringTests.waitForAnswerPollingOnlyTest`. + +#### Test suite: all bare `assert` statements migrated to JUnit assertions (#292) +1114 bare Java `assert` statements across 97 test files only ever ran because surefire enables +`-ea` by default — as `assertTrue(...)` they are independent of JVM flags and produce proper +assertion errors. Messages are preserved; dynamic messages keep the `assert` statement's lazy +evaluation via supplier arguments (except where a lambda could not capture the local, which use +eager `String.valueOf`). Behavior-preserving by construction: assertions were already enabled in +the test JVMs. + +#### Messaging: "CHANGESTREAM DUPLICATE CAUGHT" dropped from WARN to DEBUG +The guard fires whenever the change stream and the fallback poll both find the same message, +which at a 10s fallback interval is simply normal operation — production logs showed ~135 lines +a day of it, burying the handful of warnings that actually matter (found during the #285 +analysis). The deduplication behavior is unchanged, only the log level. + ### Fixed -#### PoppyDB: `--auth`/`--ssl` now work on a replica set - the internal election/replication channel was always plaintext and unauthenticated -Each of `--auth` and `--ssl`, independently, made a multi-node PoppyDB replica set completely non-functional: `ElectionNetworkClient` (vote requests, heartbeats) and `ReplicationManager` (the sync connection to the primary) connected to peers as a plain, unauthenticated, unencrypted client, regardless of the server's own `--auth`/`--ssl` configuration. With `--ssl=true` every internal connection was rejected by the peer's TLS-only listener (`NotSslRecordException`); with `--auth=true` the election RPCs (`requestVote`/`appendEntries`) aren't on the pre-auth command whitelist, so every one was rejected as unauthorized - either way, no leader could ever be elected. Single-node PoppyDB with `--auth`/`--ssl` was unaffected; the client-facing enforcement itself was never the problem. The internal channel now authenticates as the configured root user and, when TLS is on, trusts exactly the server's own configured certificate (`ssl-keystore`, reused as the internal client's pinned truststore) - no new config keys, no change to auth enforcement. +#### PoppyDB: a restarted empty node could wipe the whole replica set +Reproduced kill chain: kill one node of a 3-node RS, restart it empty (fresh data dir), and it +could both win the next election and cause the surviving, data-bearing followers to drop their +local databases to match it. Two independent holes made this possible. First, +`ElectionManager`'s Raft log-recency check existed but was vacuous — `lastLogIndex` had no +production writer, so it stayed 0 on every node and an empty restarted candidate compared as +"at least as up to date" as a voter sitting on real data. Second, on the follower side, a +replication resume that finds its window already gone falls back to a full resync, and that +fallback trusted whatever the primary reported unconditionally — reconnecting to a now-empty +primary meant "wipe local data to match" with no discriminator between a legitimately empty +primary (post-`dropDatabase`) and a stale one that had simply forgotten everything. + +The fix has three parts, each closing a different leg: +- **Vote safety**: the log-recency check now enforces the one invariant that can be honestly + made without a real replicated log — a candidate reporting index 0 never wins against a voter + sitting above 0; three empty nodes still elect cleanly on cold start. A related hole let a + freshly-synced node still report index 0 to the election (initial sync suppresses the change + stream, so the normal live-write feed never fired) — such nodes now seed their true position + right after sync completes, so they neither wrongly grant votes to an empty candidate nor get + wrongly denied candidacy themselves. +- **Candidacy restraint**: an empty node now holds off campaigning for as long as it can see a + data-bearing peer, preventing the term churn an empty node's repeated candidacies would + otherwise cause even after vote safety alone denies it the win. +- **Fail-closed resync**: a follower now refuses a destructive drop-to-match resync whenever + the primary's replication sequence at registration is *behind* the sequence the follower's own + data was last known to reflect — the discriminator that tells a restarted/stale primary apart + from a legitimately empty one, since a real primary's sequence only ever advances, including + across a replicated `dropDatabase`. The refusal logs an ERROR, keeps local data intact, and + retries with watch re-registration paced at 2s and sync-loop retry backing off exponentially + from 1s to 30s, until a genuinely caught-up primary answers or an operator intervenes; the + replication stats now expose `refusingDestructiveResync` / `refusedResyncCount` so this state + is observable rather than silent. Sequence knowledge now also carries over across leader + changes — a freshly constructed replication manager used to start its own sequence at 0 and + immediately self-seed from whatever the new leader reported, which made the guard structurally + unable to fire on that path. Change-stream sequences are primary-local: after a successful + sync/shortcut against a primary, a follower now *adopts* that primary's own counter as its new + base rather than keeping the higher of the two — the old and new primaries' counters are + unrelated numbers, and keeping a stale, inflated one made every later reconnect to that (still + perfectly healthy) primary look like a resume-window loss, which then tripped the guard against + the new primary's own honest, lower counter and refused every subsequent legitimate resync. + +Composition note, stated plainly: the resync guard is a sequence-height heuristic, not a +lineage check. A wrongly-promoted empty primary that manages to take on enough fresh writes +before a follower reconnects could, in principle, still pass it — the guard alone is not the +safety boundary. The actual barrier against that scenario is the election-side fix: an empty +node must never be able to win the election in the first place, which is what vote safety and +candidacy restraint together guarantee — guaranteed for the single-restart case; if a majority +of nodes restart empty simultaneously, an empty node can still be elected (the fail-closed resync +then still protects each surviving node's local data, but the cluster serves empty until a +data-bearing node takes over). The resync guard is defense in depth on top of that, not a +substitute for it. + +Operator note: if the *last* data-bearing node in a cluster dies permanently, the surviving +empty nodes deliberately hold back candidacy indefinitely rather than elect one of themselves — +restarting any one of the survivors clears its peer-index memory and lets the cluster elect +again, so recovery is "restart one node", not "restart the cluster". + +Regression coverage: `EmptyNodeRestartWipeTest` reproduces both directions of the original bug +(empty node restarted as would-be primary, and as a would-be follower reconnecting to an empty +primary) against a real in-process 3-node replica set. + +#### PoppyDB: j:true write concern no longer promises durability that does not exist +A `j: true` write concern was silently accepted and acknowledged although PoppyDB has no +journal (persistence is periodic snapshots). Like mongod running without journaling, the +write is still executed but the answer now carries `writeConcernError` code 2 (`BadValue`), +so clients relying on journal durability learn the truth instead of getting a hollow +acknowledgement. + +#### PoppyDB: secondaries no longer serve reads that defaulted to primary read preference +MongoDB's default read preference *is* `primary`, but only an explicit `mode: "primary"` was +rejected on secondaries — a read without `$readPreference` was silently served, returning +possibly-stale data to a client that (by default) asked for primary consistency. Such reads +now get `NotPrimaryNoSecondaryOk` (13435), matching mongod's handling of a direct secondary +connection without `secondaryOk`. Morphium's own wire commands always send a read preference +(default `primaryPreferred`) and are unaffected. + +#### InMemoryDriver: MongoDB collation strength mapped to the wrong Java collator level +MongoDB collation strength (1=primary..5=identical) was passed straight to +`java.text.Collator.setStrength()`, whose constants are 0-3. Every level was silently shifted +by one — `strength: 1` behaved as SECONDARY (diacritics significant) instead of PRIMARY — and +`strength: 4`/`5` threw an `IllegalArgumentException` instead of working at all. The values are +now mapped explicitly; Java has no quaternary level, so 4 and 5 both map to IDENTICAL, the +closest level at least as strong as what mongo promises. + +#### PoppyDB: find fast path ignored the client's collation (#252 follow-up) +The #252 fix wired the request's `collation` through the update/delete/count/distinct wire +fast paths but missed `find`: a collation-aware find matched differently depending on which +internal dispatch path the request happened to take. The collation now reaches the driver on +both the single-shot and the cursor-window path, and the server-side find cursor carries it so +`getMore` refills re-execute the query with the same collation as the first batch. + +#### InMemoryDriver: bulk-insert writeErrors pointed at the wrong batch positions, n overcounted +The insert path removes failed documents from its working list between its three +error-detection passes (oversize, duplicate against committed docs, intra-batch duplicate), so +every `writeErrors.index` reported after an earlier removal referred to the shrunken working +list — but clients resolve those indexes against the batch *they* sent. A parallel +original-index list now keeps the reported indexes stable; removal is position-based, which +also stops an equal-but-different document elsewhere in the batch from being dropped +collaterally. In addition, `n` was computed as `batchSize - writeErrors.size()` on both the +generic and the PoppyDB fast path — correct for unordered inserts only. An ordered insert +stops at the first error, so the never-attempted tail was counted as inserted; both paths now +derive the committed count from the first error's batch index. + +#### PoppyDB: commitTransaction/abortTransaction failures were swallowed +A `commitTransaction`/`abortTransaction` that threw was only logged — the client received an +unconditional `ok:1` and believed its transaction was committed. Failures are now answered as +a mongo-shaped error (code 8 `UnknownError`, or the driver's mongo code if it attached one). +Commit/abort without an active transaction remains a lenient `ok:1` no-op; a full per-session +transaction state machine (txnNumber validation, `NoSuchTransaction`) is deliberately out of +scope here. + +#### Write buffer: remove-by-query deleted only a single document +`BufferedMorphiumWriterImpl.remove(Query, multiple, callback)` accepted the `multiple` flag but +never passed it on to the queued `DeleteBulkRequest`, whose default is `multiple = false`. All +drivers translate that faithfully into `delete ... limit: 1` — so for any `@WriteBuffer` entity, +`morphium.remove(query)` and `clearCollection()` silently deleted exactly one matching document +and left the rest in place. The bug had been masked for years because the InMemoryDriver bypasses +the buffered writer entirely (`getWriterForClass`), so no in-memory test could see it, and the +one test that exercised the path against real servers (`CacheSyncTest.idCacheTest`) tolerated +lost objects until the #292 sleep→condition hardening turned its settle sleep into a hard count +assertion — which then failed on all four CI server phases and exposed the root cause. The flag +is now propagated; a regression test (`BufferedWriterTest.testWriteBufferRemoveByQuery`) covers +partial and full remove-by-query on a write-buffered entity. The dead skeleton +`driver/wire/BulkContext` (every driver call commented out, no remaining references) was removed +in the same change. + +#### Messaging: legacy documents with processed_by: null are deliverable again (#291) +A stored message whose `processed_by` is an explicit `null` made the pre-exec marking fail on +mongod ("Cannot apply $addToSet to non-array field … has non-array type null") — and since +6.3.x requires exclusive messages to be marked *before* the listener runs, that turned into a +hard non-delivery: no listener call, no answer, `sendAndAwait` timeout. Morphium senders can't +produce such documents (Msg's `@PreStore` initializes the field), but foreign writers mapping +the same collection without that guard, raw-driver writers and restored dumps can — observed in +production against a consumer upgraded from 6.2.4, where the same failed write had merely been +log noise after processing. All marking sites in all three implementations (plus the rejection +handler) now fall back to an atomic repair: `{processed_by: null}` → `{$set: [own id]}`, +guarded so an existing array is never clobbered. The InMemoryDriver previously masked the whole +class by treating explicit null like a missing field for `$addToSet`/`$push` (creating the +array); it now rejects it exactly like mongod, so the scenario is testable in-memory. +`getIndexStore()` is reachable without the collection lock (explain and slow-query logging), so +its from-scratch build could race any write that invalidates the store — most visibly +`createUser`: the build snapshots the documents, the write lands and invalidates, and the build +then publishes its pre-mutation snapshot anyway. That store passed the provenance check for +every later reader and stayed authoritative until the next invalidate; in the worst case the +duplicate-`_id` check ran against it and admitted a second document with the same `_id`. Every +invalidation now bumps a per-collection epoch *before* removing the store, builds sample it +before snapshotting, and a build whose epoch moved is not published (checked again after the +publish, so a full invalidate landing between check and publish is undone too). Whole-DB drops +and `resetData()`, which discard stores in bulk without `invalidateIndexStore()`, get the same +fencing via a global drop epoch — a build racing a `dropDatabase` could previously resurrect +the dropped collection's index store, pre-drop documents included. The explain/slow-query paths +stay lock-free: a refused build is still returned to its caller for that one read, it just +never becomes visible to anyone else. + +#### InMemoryDriver: literal array queries support whole-array equality ({field: []} et al.) +A literal query with an array operand only ever matched via the multikey "array contains the +operand as an element" rule; MongoDB additionally matches when the document's array *is* the +operand (order-sensitive). Most visibly, `{processed_by: []}` — the empty-array form services +use against messaging collections — matched nothing at all, and on dotted paths the resolver +flattened leaf arrays into their elements so an empty array contributed no match candidates +whatsoever. Both query engines (interpreter and compiled) now check whole-array equality with +the same id/number normalization as scalar comparison ([1, 2] matches [1L, 2.0]), on plain and +dotted paths. Found during the mongorestore rehearsal for the acceptance drop-in test. + +#### InMemoryDriver: unique+sparse indexes no longer throw false duplicate-key errors +A `unique: true, sparse: true` index (the classic optional-email pattern) rejected the second +document that lacked the indexed field with E11000 — both the index store and the insert-path +pre-check treated the missing key as a colliding value. Per MongoDB semantics, documents +containing none of a sparse index's fields are not part of the index and cannot collide; the +uniqueness check now skips them (documents with present fields are still enforced). Also fixed +in passing: decoding a BSON MaxKey threw "unknown data type" due to a missing `break`. + +#### InMemoryDriver: unique partial indexes enforced uniqueness over the whole collection +A `unique` index with a `partialFilterExpression` was created and reported with its filter, but +the filter was never evaluated: uniqueness was enforced against every document, so a schema like +JEF's task queue (`{msg_id:1}, unique, partialFilterExpression {msg_id:{$type:"objectId"}}`) +rejected the *second* document without an `msg_id` — or with a non-ObjectId one — with E11000, +where mongod accepts any number of them. Documents outside the filter are not part of a partial +index in MongoDB and cannot collide in it; the index store now honours that. The filter cuts both +ways: a stored document that does not match no longer counts as a collision partner either, which +matters when the filter selects on a field outside the index key (uncovered and covered documents +then share a key bucket). Found during the PoppyDB drop-in rehearsal for the acceptance messageBus +cluster, verified against mongod 8.0. + +The follow-up review of this fix surfaced three more gaps, all closed: +- `insert()`'s legacy O(collection)-scan unique pre-check had gotten the cuts-both-ways half + wrong (it exempted only the incoming document, still raising the false E11000 the store fix + removed). It re-implemented the index-membership rules separately from the store, which its own + comments already declared the single uniqueness authority — deleted outright; committed and + intra-batch conflicts alike now surface via `CollectionIndexStore.onInsert`, with mongod's + actual ordered semantics (stop at the first error). +- An update that leaves the index key untouched but moves a document *into* the partial filter + now runs the uniqueness check too — before, it silently created two covered documents on one + unique key, a state mongod rejects with E11000 and the store's own rebuild would refuse. +- TTL expiry honours `partialFilterExpression`: a TTL index with a filter no longer deletes + uncovered documents (mongod's TTL monitor never touches them). The partial filter is also + compiled once per index definition now instead of being re-interpreted through the global + query cache on every write. + +## [6.3.1] - 2026-08-11 ### Added +#### Messaging: implementation mismatches between queue participants are detected (#280) +All three messaging implementations use incompatible collection layouts, and a mixed queue used +to fail *silently* in the worst direction: broadcasts kept flowing while answers landed in a +collection the other side never reads. Every messaging instance now announces its implementation +on startup in a layout-independent `_participants` collection (heartbeat on the +`messagingRegistryUpdateInterval`, stale entries pruned, withdrawn on `terminate()`) and checks +what the other participants run. The channel is deliberately *not* the messaging itself — between +two implementations without a shared collection, a messaging-based warning would never arrive. +On a mismatch the default is a WARN log; `MessagingSettings.ImplementationCheck.THROW` makes a +mismatched instance refuse startup with an `IllegalStateException`, `IGNORE` disables +announcement and check entirely. Detection and diagnostics only — no bridging. The participants +entity reads from the primary on purpose: under replication lag a secondary read could miss an +announcement made moments ago (seen as exactly that on the loaded replica-set test phase). + +### Changed + +#### Messaging: the main change stream filters server-side (#283) +Every consumer's change-stream cursor used to receive every insert into the messaging +collection — including messages addressed to other recipients, full payloads of large foreign +answers included. Under high traffic the cursor fell behind and delivery degraded to +fallback-poll latency. The main change stream is now built with a server-side `$match` restricted +to what the instance can actually process: messages addressed to it, broadcasts for topics with a +registered listener, and answers (broadcast answers bypass the topic clause). The stream is +rebuilt when the registered topic set changes. V5-legacy senders store only `name` instead of +`topic` — the filter matches both, so legacy documents keep flowing. + +#### PoppyDB: replication applies events on arrival +Replication events were applied on a 5 ms flush tick; they are now applied when they arrive, +noticeably reducing secondary lag. + +### Fixed + +- **Messaging: the lock-release change-stream callback no longer queries (#286).** It ran a + `countAll` per deleted lock on the change-stream thread itself, so a burst of lock releases + stalled the stream (`msg_lck` stalls). Replaced by a counter that coalesces any number of lock + events into a single poll. +- **InMemoryDriver: equality queries on an indexed array field silently returned nothing (#289).** + The index store does not implement multikey indexes, but the planner used such indexes anyway — + an index-backed `find`/`count` on e.g. `processed_by == "X"` returned an empty result. Indexes + are now flagged multikey as soon as a document stores a list in an indexed field — including + arrays crossed *mid-path* (an index on `a.b` over `{a: [{b: …}]}`) — and excluded from query + planning; such queries scan and evaluate MongoDB's array semantics correctly. +- **InMemoryDriver: change-stream events could arrive out of order under load.** Client-mode + dispatch submitted each event as its own task to a cached thread pool, which preserves no + submission order — two back-to-back events could reach a subscriber swapped, or even + concurrently. Delivery now runs on a single dispatcher thread (unbounded queue, writers never + block), restoring mongod's per-cursor ordering guarantee. +- **InMemoryDriver: `update` and `replace` change-stream types now match mongod (#288).** An + update without `$` operators (a client's `replaceOne`) emitted no event at all — invisible to + every watcher including PoppyDB replication; it now emits `replace` with the new `fullDocument` + and no `updateDescription`. And `store()` of an existing document emitted `replace`, where the + ORM's store goes out on the wire as a `$set` update that mongod reports as `update` — it now + emits `update` with a computed `updateDescription`. +- **InMemoryDriver: collection and index-descriptor creation are atomic.** Two racing first + writes (e.g. concurrent `createUser`) could both observe "collection absent" and both win. +- **Messaging: a failed main-change-stream rebuild is retried.** The topic-filter snapshot was + committed before the new monitor had started; if starting it failed, the staleness check + considered the filter current and the instance kept running without a main change stream. +- **Messaging: the listener registry is no longer mutated in place** (status-info listener + toggles, `terminate()`) while the poll thread iterates it — a + `ConcurrentModificationException` risk; the field is volatile now and all mutations + clone-and-swap. +- **Build: the parent POM's `` had regressed to `v6.2.7`**; development iterations + point at `HEAD` again. + + +## [6.3.0] - 2026-08-09 + +### Added + +#### `DualChannelMessaging` — a third messaging implementation, in beta (#265) +Load measurements showed that request/reply throughput on MongoDB is *delivery*-bound rather than +write-bound: a single change-stream cursor hands out majority-committed events at a fixed cadence, +which caps sustained request/reply throughput regardless of the offered rate. +`MultiCollectionMessaging` did better in those runs — but not because of its per-topic collection +split (on mongod every cursor tails the whole oplog anyway); the effective mechanism was its +*second* cursor for answers and DMs. `DualChannelMessaging` ports exactly that one mechanism onto +the Standard layout: identical single collection and cursor for broadcast/topic traffic, plus a +dedicated per-recipient collection `_dm_` with its own change-stream cursor and +dispatcher thread for directed messages and answers. Select it with +`cfg.messagingSettings().setMessagingImplementation("DualChannelMessaging")`. **Every participant +on a given queue must run the same messaging implementation** — there is no dual-read/dual-write +bridge between the collection layouts, and a mismatch fails silently: a `SingleCollectionMessaging` +node awaiting an answer from a `DualChannelMessaging` responder times out forever, because the +answer is written to the requester's DM collection, which the other implementation never reads. +The same applies to `MultiCollectionMessaging`, whose per-topic layout shares no collection with +the other two. Every `DualChannelMessaging` instance logs a WARN on startup restating this. +Marked **beta**: the measured benefit is smaller +and more nuanced than the original motivation suggested — past saturation it trades a little +throughput against markedly better tail latency (p99 519 ms vs 723 ms for Standard and 2044 ms +for MultiCollection in the steady-state window) — so it is opt-in while it gathers real-world +mileage. See `docs/howtos/messaging-implementations.md` for the full comparison. + +#### `dropUser` — the user lifecycle is complete (InMemoryDriver + PoppyDB) +The in-memory driver (and with it PoppyDB) now implements mongod-compatible `dropUser`: the user +document is removed and a delete event is emitted on `admin.system.users` under the same +ordering lock as `createUser`/`updateUser`, so PoppyDB secondaries replicate the drop exactly +like creates and updates (documentKey-keyed delete). On a replica set the command is +primary-only like every other write - a secondary answers `NotWritablePrimary`. Previously the +only way to remove a user was a raw delete on `admin.system.users`, which bypassed the +event-ordering guarantee and was not wired into any command surface. + +#### `customData` support in `createUser`/`updateUser` +`createUser` stores an optional `customData` document on the user (mongod's shape); +`updateUser` accepts `customData` — replaced wholesale when given (including as the only field, +which previously returned `BadValue`), preserved when omitted. A password change no longer +silently discards stored `customData`. `authenticationRestrictions` remains unmodeled. + #### Driver: automated failover test via wire-rewriting proxy, replaces manual `FailoverReproTest` `FailoverReproTest` reproduced the 6.2.6 failover regressions but required a hand-built local replica set and process kills (`kill -9`, SIGSTOP) run by hand — it was tagged `manual` and never @@ -50,6 +467,84 @@ will follow in subsequent PRs. The code originates from which is being archived now that its content has moved into the main Morphium repository. See [Jakarta Data](docs/jakarta-data.md). +#### `quarkus-morphium` — optional Quarkus extension for CDI integration +A new optional module, `quarkus-morphium`, integrates Morphium into +[Quarkus](https://quarkus.io) applications: a CDI producer for `Morphium`, type-safe +runtime configuration via `@ConfigMapping` (`quarkus.morphium.*`), declarative +`@MorphiumTransactional` transactions with `MorphiumTransactionEvent` CDI events +(graceful degradation on Azure CosmosDB, auto-detected), MicroProfile liveness/readiness/ +startup health checks via SmallRye Health, Dev Services (an automatically-started MongoDB +container, optionally as a single-node replica set), a Dev UI card with live connection +info, build-time Jakarta Data `@Repository` implementations generated via Gizmo bytecode +(no runtime reflection, no dynamic proxies — see [Jakarta Data](docs/jakarta-data.md) for +the underlying query-derivation, JDQL, and pagination feature set), GraalVM native-image +support (automatic reflection registration for every `@Entity`/`@Embedded` class), default +`MorphiumId` JSON serialization as its canonical 24-character hex string (both Jackson and +JSON-B, in both directions), and a MongoDB-backed migration runner with a distributed lock. +The module publishes three artifacts — `quarkus-morphium` (runtime), `quarkus-morphium-deployment` +(build-time processing), and `quarkus-morphium-testing` (test support) — plus an +`integration-tests` submodule that is built and run but never published. Like +`morphium-jakarta-data`, the core has zero compile- or runtime dependency on this module; +building the reactor with `-DskipExtensions` produces an unchanged core-only build. The +integration tests spin up a real MongoDB via Testcontainers and therefore need a running +Docker daemon — when Docker is unavailable, they detect this and skip themselves rather than +failing the build. **groupId migration:** this extension previously published under +`io.quarkiverse.morphium` as part of the Quarkiverse organization; because it does not +actually live in the [Quarkiverse](https://quarkiverse.github.io) GitHub organization, +Maven coordinates now follow Morphium's own groupId, `de.caluga:quarkus-morphium`, and +version in lockstep with the Morphium reactor. **Existing users of +`io.quarkiverse.morphium:quarkus-morphium:1.2.0` must update their dependency's groupId to +`de.caluga` and its version to the Morphium version they adopt (currently `6.3.x`)** — no +package renames, no API changes, only the Maven coordinates move. The code originates from +[Bardioc1977/quarkus-morphium](https://github.com/Bardioc1977/quarkus-morphium), which is +being archived now that its content has moved into the main Morphium repository. See +[Quarkus Extension](docs/quarkus-extension.md). + +#### `spring-boot-morphium` — optional Spring Boot integration module +A new optional module, `spring-boot-morphium`, integrates Morphium into +[Spring Boot](https://spring.io/projects/spring-boot) applications: `MorphiumAutoConfiguration` +creates the application's `Morphium` bean from `morphium.*` properties (type-safe +`@ConfigurationProperties`, with `spring-boot-configuration-processor`-generated metadata for +IDE autocompletion), and connection retry with linear backoff on transient failures. +Jakarta Data `@Repository` +interfaces (`CrudRepository`/`MorphiumRepository` from `morphium-jakarta-data`) are wired via +`MorphiumRepositoryRegistrar` at Spring context-startup time, backed by a JDK dynamic proxy +(`java.lang.reflect.Proxy`) per repository interface — in contrast to `quarkus-morphium`, which +generates repository implementations as Gizmo bytecode at build time; here everything is +runtime reflection, no annotation processor or build-time codegen involved. Declarative +`@MorphiumTransactional` transactions wrap the annotated method body in +`startTransaction()`/`commitTransaction()`/`abortTransaction()` via an AspectJ `@Around` advice, +active only when `spring-boot-starter-aop` is on the classpath. An Actuator `HealthIndicator` +reports live MongoDB connection status (database, driver, replica-set state) under +`/actuator/health`, active only when `spring-boot-actuator` is present and a `Morphium` bean +already exists; a user-defined bean named `morphiumHealthIndicator` correctly overrides the +auto-configured one. The module publishes three artifacts — `morphium-spring-boot-starter`, +`morphium-spring-boot-autoconfigure`, and `morphium-spring-boot-test` (a `@MorphiumTest` +composite annotation that wires `InMemDriver` into a `@SpringBootTest`, so repository tests run +without a MongoDB instance or container) — and, unlike `quarkus-morphium/integration-tests`, +`morphium-spring-boot-test` is a genuine end-user artifact, not an internal test suite, and is +published to Central like the other two. Like `morphium-jakarta-data` and `quarkus-morphium`, +the core has zero compile- or runtime dependency on this module; building the reactor with +`-DskipExtensions` produces an unchanged core-only build. No Docker/Testcontainers dependency +anywhere in the module — all tests run against Morphium's `InMemDriver`, unlike +`quarkus-morphium`'s integration tests, which need a running Docker daemon. +**Two coordinate/naming corrections made during the pre-integration conversion:** the three +modules were renamed from `spring-boot-morphium-*` to `morphium-spring-boot-*`, following the +Spring Boot starter naming convention (the `spring-boot-` prefix is reserved for Spring's own +starters); and the configuration property prefix was renamed from `spring.morphium.*` to +`morphium.*`, since the `spring.*` namespace is reserved for Spring Boot's own configuration +keys. Both renames happened before any Maven Central release of this module existed, so they +carry zero breaking-change cost. **Existing users of the pre-integration +`de.caluga:spring-boot-morphium-starter:1.0.0-SNAPSHOT`** must update their dependency's +artifactId to `morphium-spring-boot-starter`, its version to the Morphium version they adopt +(currently `6.3.x`), and rename every `spring.morphium.*` key in their +`application.properties`/`.yml` to `morphium.*` (e.g. `spring.morphium.database` → +`morphium.database`) — no Java API changes; `MorphiumProperties`, `@EnableMorphiumRepositories`, +`@MorphiumTransactional`, and all other public types are unaffected. The code originates from +[Bardioc1977/spring-boot-morphium](https://github.com/Bardioc1977/spring-boot-morphium), which +is being archived now that its content has moved into the main Morphium repository. See +[Spring Boot](docs/spring-boot.md). + #### PoppyDB: `--users-file` — declarative user provisioning (bootstrap, upsert, version-gated) Builds on user replication: `--rootUser`/`--rootPassword` only ever provisioned one admin user, so any real application user set still had to be created by hand (a shell script running @@ -203,8 +698,26 @@ When the change-stream listener of `MultiCollectionMessaging` skipped a message #### Messaging: change-stream liveness drives the fallback poll The change-stream watch loop receives a server reply at least every `maxTimeMS` (an empty batch when there are no events); that heartbeat is now stamped on the `WatchCommand` and exposed as `ChangeStreamMonitor.isStreamLive()`. Both messaging implementations use it to poll *immediately* when a stream falls silent — faster than any timer — instead of waiting for the next interval. The regular `messagingFallbackPollInterval` poll still always runs, deliberately: messages can (re-)appear without any matching stream event, e.g. requeueing by clearing `processedBy` via a plain DB update, and must be found before their TTL expires. `SingleCollectionMessaging` (whose own counter-based gate effectively polled every ~25s) now honors the configurable interval too, and gets the catch-up poll on every watch (re-)establishment for its message and lock monitors — including the one recreated by its stall watchdog. New diagnostics: `MultiCollectionMessaging.topicStreamsLive(topic)` and `SingleCollectionMessaging.changeStreamsLive()`. + +#### InMemoryDriver: the `$merge` aggregation stage is implemented (#241) +`$merge` previously reported success and wrote nothing at all — every persistence call was commented-out dead code — so pipelines materialising results (rollups, denormalised views, ETL-style flows) silently produced no data. It now works: `whenMatched` `merge` (default, incoming fields win) / `replace` / `keepExisting` / `fail`, `whenNotMatched` `insert` (default) / `discard` / `fail`, `on` defaulting to `_id` and accepting a single field or a list, and `into` as a collection name or `{db, coll}`. `merge` and `replace` preserve the target document's `_id`; ambiguous `on` matches and documents missing an `on` field are refused rather than silently guessed; `$merge` is terminal and yields no documents. Writes go through the driver's `find()`/`store()`, so index maintenance, capped/TTL bookkeeping, locking and watcher events all happen. `whenMatched` may also be a custom update pipeline: it runs per match with the existing target document as input and the incoming document bound to `$$new`, supports the stages mongod allows there (`$addFields`/`$set`, `$project`/`$unset`, `$replaceRoot`/`$replaceWith` — anything else is refused), and honours `let` (which, as in mongod, *replaces* the default `{new: "$$ROOT"}`, is evaluated against the incoming document, and is rejected when `whenMatched` is not a pipeline). References to undefined `$$variables` fail up front instead of evaluating to null; the pipeline result keeps the target document's `_id`. + ### Changed +#### InMemoryDriver: the change-stream before-image is no longer deep-copied twice per watched update (#274) +With a change-stream subscriber on the namespace, `updateInternal` already takes a full `deepClone` of the document before mutating it — and then handed that clone to `notifyWatchers`, which deep-copied it a *second* time when building the event. The second copy existed only because `buildChangeStreamEvent` treated both images the same way, not because anything needed it: once the notification is queued, nothing in the update path reads or mutates that clone again, so the change-stream path is its sole owner and all the second copy contributed was another full recursive walk of the document plus a duplicate of its entire nested structure. The before-image is now adopted as-is on exactly that path, with only the `_id` normalization still applied. On a deeply-nested document (~580 nested maps/lists) with an active watcher this removes ~163 KiB of allocation per update, about 7% of the whole update's allocation — the wall-clock effect stays inside run-to-run noise, since the remaining traversals (after-image copy, `updatedFields`/`removedFields` flattening, `updateLookup`) dominate. + +Deliberately narrow, and gated by an explicit `beforeDocumentIsExclusiveCopy` flag rather than applied to `buildChangeStreamEvent` as a whole, because on every other path the before-image is *not* exclusively owned: the delete paths pass the live stored document as both after- and before-image, `store()`'s replace branch passes the document it just unlinked, and an update without subscribers or transaction passes a `buildPartialBeforeImage` result that still shares untouched nested containers with the live document. Those all keep the real deep copy. The **after**-image keeps its unconditional deep copy on every path without exception — it references the live, in-place-mutated stored document, and a shallow variant of that copy was already tried once and reverted the same day (cf3e9cace). + +#### InMemoryDriver: insert's duplicate-`_id` pre-check is an O(1) index lookup instead of an O(N) collection scan +Every `insert()` call built a `HashSet` of all existing `_id`s by iterating the entire collection — under the exclusive write lock. For single-document inserts into large collections (the messaging workload) that scan was the dominant per-insert cost, and it was redundant: the per-collection `CollectionIndexStore` always carries a unique `_id_` index that reflects exactly the committed documents. The pre-check now asks that index directly (new `CollectionIndexStore.containsId`, a single hash lookup). Semantics are unchanged: ordered inserts still throw on a committed duplicate, unordered ones still collect a code-11000 writeError, and duplicates *within* one batch still surface at the per-document index insert, as before. As a side effect the check now uses the index's `MorphiumId`/`ObjectId` normalization, so a duplicate no longer slips past the pre-check just because caller and store hold the same id in different wrapper types. + +#### PoppyDB: dead `locked_by`/`locked` messaging index removed +`MessagingOptimizer` created a `msg_locked_by_1_locked_1` index on every registered messaging collection, but those fields no longer exist on `Msg` — locking moved to the separate `MsgLock` collection long ago. Nothing ever queried the index; it only added per-insert maintenance cost on the hottest collection. Removed. + +#### Messaging: non-exclusive messages are processed from the change-stream `fullDocument` — one DB roundtrip less per message +`SingleCollectionMessaging` re-read every message by `_id` (PRIMARY read preference) before processing, although the insert event already carried the complete document. For the safe case — non-exclusive messages arriving via an insert event with a `fullDocument` — the change-stream handler now attaches the event snapshot to the processing queue element and the processing runnable deserializes it directly; all skip checks (listener existence, sender==self, processed-by, recipients, answer matching) run unchanged against the deserialized message. Everything with staleness risk deliberately keeps the re-fetch: exclusive messages (the `processed_by` re-check after claiming the lock is correctness, not overhead), requeue updates, poll pickups, and any snapshot that fails to deserialize. The decision trace records which path was taken. + #### InMemoryDriver/PoppyDB: dbStats and collStats report real sizes instead of zeros `db.stats()` answered all byte-size fields with 0, and `collStats` reported jol's *shallow* `sizeOf` — the ArrayList object header, not the data (and NPE'd on a missing collection). Both now compute real values: `dataSize`/`size` is the actual BSON size of every document (mongod's definition; computed on demand, O(data) — fine for a diagnostic command), `storageSize` equals it (no padding or compression in memory), `avgObjSize` follows, and index sizes are estimates proportional to the entry count (64 bytes per document per index). New fields: `totalSize`, and on dbStats `fsUsedSize`/`fsTotalSize` reporting the JVM heap — the "filesystem" an in-memory database actually lives on. Index counts now include the implicit `_id` index like mongod. The `$collStats` aggregation stage's `storageStats` uses the same computation; `collStats` on a missing collection answers zeros instead of failing. @@ -214,12 +727,167 @@ The change-stream watch loop receives a server reply at least every `maxTimeMS` #### InMemoryDriver: O(1) change-stream replay-buffer bound The ring-buffer bound check in `notifyWatchers` used `ConcurrentLinkedDeque.size()` — O(n), ~200k node traversals per write at PoppyDB's 100k-event replay bound. The deque size is now tracked in an `AtomicInteger`; eviction semantics are unchanged. -### Added +### Fixed -#### InMemoryDriver: the `$merge` aggregation stage is implemented (#241) -`$merge` previously reported success and wrote nothing at all — every persistence call was commented-out dead code — so pipelines materialising results (rollups, denormalised views, ETL-style flows) silently produced no data. It now works: `whenMatched` `merge` (default, incoming fields win) / `replace` / `keepExisting` / `fail`, `whenNotMatched` `insert` (default) / `discard` / `fail`, `on` defaulting to `_id` and accepting a single field or a list, and `into` as a collection name or `{db, coll}`. `merge` and `replace` preserve the target document's `_id`; ambiguous `on` matches and documents missing an `on` field are refused rather than silently guessed; `$merge` is terminal and yields no documents. Writes go through the driver's `find()`/`store()`, so index maintenance, capped/TTL bookkeeping, locking and watcher events all happen. `whenMatched` may also be a custom update pipeline: it runs per match with the existing target document as input and the incoming document bound to `$$new`, supports the stages mongod allows there (`$addFields`/`$set`, `$project`/`$unset`, `$replaceRoot`/`$replaceWith` — anything else is refused), and honours `let` (which, as in mongod, *replaces* the default `{new: "$$ROOT"}`, is evaluated against the incoming document, and is rejected when `whenMatched` is not a pipeline). References to undefined `$$variables` fail up front instead of evaluating to null; the pipeline result keeps the target document's `_id`. +#### InMemoryDriver: a single insert after a TTL-queue invalidation stopped every older document from ever expiring (#269) +The TTL sweep is queue-driven, and `invalidateTtlQueue()` discards a collection's queue +outright at every structural change (drop, clear, rename, transaction commit/abort), relying +on a lazy rebuild-on-miss - the same discard-and-rebuild contract the persistent index store +uses. But only one of the two code paths that can find the queue missing actually rebuilt it: +`sweepTtlQueue()` bootstrapped from a full scan, while `ttlEnqueue()` used `computeIfAbsent` +and put a fresh, otherwise-EMPTY queue in place holding nothing but the one document it was +called for. That queue is no longer absent, so the sweep's bootstrap-on-miss never fired +again and every document that existed before the invalidation permanently lost its expiry +tracking - it would only ever come back through another structural event that happened to +invalidate the queue again at a quieter moment. + +Why it matters beyond the in-memory driver: `Msg.deleteAt` carries +`@Index(options = "expireAfterSeconds:0")`, so this is the exact mechanism Morphium's +messaging relies on to clean up processed messages, and PoppyDB runs on this driver. A +messaging node starting against a PoppyDB that already holds messages opens precisely this +window - the `MessagingOptimizer` registers the messaging collection (structural index work) +and the first message inserted afterwards lands before the next sweep tick - after which the +pre-existing messages were never expired again and the `msg` collection grew without bound. + +`ttlEnqueue()` now bootstraps on miss exactly like the sweep does. Two details this needed +care with: every call site runs *after* its document is physically in the collection and in +the index store, so the bootstrap scan has normally already queued it and re-adding it would +double-enqueue - guarded by an explicit check rather than an assumption, since the bootstrap +can legitimately miss it (a renamed collection carries no index definitions over, leaving +nothing to scan). And the bootstrap requires the collection's write lock, which all five +`ttlEnqueue()` call sites (`insert`, `storeInternal`, `updateInternal`) already hold, so no +new lock is taken and no ordering is introduced. + +#### InMemoryDriver: index-store provenance mismatch evicted the entry, causing a rebuild ping-pong between a transaction and concurrent readers +Follow-up to the provenance fix. On a mismatch, `getIndexStore()` evicted the offending +entry before rebuilding, and a transaction whose entry got evicted then lost the race to +publish its own store forever: the surviving entry kept winning `putIfAbsent`, so that +transaction rebuilt its index store on every single operation for its whole lifetime. A +first attempt removed the eviction but left the mismatching entry in place unowned, which +fixed the rebuild storm but left a leftover foreign entry sitting in the map. The entry now +instead changes owner atomically once the rebuild finishes, via a compare-and-swap keyed on +the exact entry this call observed - a same-key swap rather than a remove-then-publish, so +there is never a moment with no entry for the key. Measured on 5000 documents and 20 +operations inside a transaction that runs against a pre-existing store: 20 `buildIndexStore` +passes with the entry evicted, 1 with the CAS; a purely non-transactional caller (no +transaction open at all) sees 0 either way. Same numbers for one secondary index and for +two. Since `buildIndexStore` is O(documents x indexes) this worked against the "cost +proportional to what a transaction touches" property the lazy rebuild was introduced for. +The swap also never creates a "no entry present" window, which two lock-free callers (the +`ExplainCommand` path in `runCommand`, and `recordAggregateSlowQueryIfNeeded`) could +otherwise use to publish a store built from a document list another thread is mutating. + +#### Messaging: change-stream fullDocument fast path skipped `@PostLoad`, silently dropping V5-legacy messages that only carry a `name` field +The non-exclusive fast path introduced with the fullDocument optimization deserialized the +change-stream snapshot via the raw `ObjectMapper`, which - unlike the query path - fires no +entity lifecycle callbacks. `Msg.postLoad()` is exactly where the V5→V6 compatibility +migration lives (`topic = name` when only the legacy `name` field is set), so a message +inserted externally in V5 format without a `topic` field (e.g. via `storeMap()`, as +`V5V6CompatibilityTest` simulates) arrived with `topic == null` and was silently discarded by +the "no listener registered for this topic" check - no exception, no fallback, on every +backend. The fast path now fires `firePostLoadEvent()` right after a successful deserialize, +matching the query path; if the callback throws, the message falls back to the pre-existing +re-fetch path. + +#### InMemoryDriver: aborted/committed transactions could leave stale `CollectionIndexStore` entries, causing false duplicate-key errors on a provably empty collection +A persistent `CollectionIndexStore` lazily built while a transaction is open is built from +the transaction's private snapshot, i.e. from structurally-cloned document instances rather +than the live ones. Those clones were registered into the store's unique-index buckets same +as any real document. `commitTransaction()` already invalidated the store for every +collection the transaction touched, but `abortTransaction()` did not - so on abort the store +kept referencing the orphaned clones forever, since removal matches only by reference +identity and can never match a clone against the real document it was copied from. Every +later insert under that same unique-index key was then rejected as a duplicate, even after +the live collection had been cleared to zero documents. Both `abortTransaction()` and +`commitTransaction()` now invalidate the index store (and TTL queue) for every collection +whose store was actually built while the transaction was open, not merely the ones it wrote +to, since a read-only indexed query can trigger that same lazy rebuild without ever writing. + +#### InMemoryDriver: a `CollectionIndexStore` built before a transaction started stayed stale for the whole transaction, silently losing an update on commit +The previous fix only covers a store built DURING a transaction. A store built BEFORE one - +the common case, since most collections already have a store from earlier reads or writes - +was never touched by that invalidation at all. Such a store was built by reading through the +live database and holds live document instances; a transaction's writes then mutate its +private cloned snapshot instead, without that pre-existing store ever finding out. An +index-backed read inside the transaction (an equality lookup on a secondary index) kept +returning the pre-transaction live instance, diverging from a full scan of the same +collection, which does read through the transaction's snapshot. Worse, an update whose +candidate document came from that stale index-backed lookup mutated the live object instead +of the snapshot clone the commit actually merges back, so the write was silently lost after +commit even though it succeeded without error inside the transaction. `getIndexStore()` now +records which transaction context (if any) each persistent store was built from and reuses a +store only for the caller it was built for - rebuilding lazily on first access rather than +eagerly discarding every collection's store at transaction start. Keying this by context +identity rather than by build order matters because `currentTransaction` is thread-local and +transactions genuinely overlap: it stops two concurrent transactions from borrowing each +other's store (which would let one transaction's index-backed update land in the other's +snapshot) and stops a reader outside any transaction from observing an open transaction's +uncommitted writes through a store seeded with that transaction's clones. + +#### PoppyDB: a re-syncing secondary broadcast its own initial-sync wipe as change-stream drop events, letting stale watchers destroy `admin.system.users` cluster-wide during a stepdown +The initial sync's `clearLocalDatabases()` wipe and snapshot copy ran as regular commands and +therefore emitted live change-stream events on the syncing node - including +`drop admin.system.users`. During a live stepdown that is catastrophic: the demoted ex-primary +immediately starts re-sync attempts toward the presumed new leader (each failed retry wiping +again), while the other nodes' OLD ReplicationManagers are still watching the demoted node +(they only tear down once their own ElectionManager delivers the leader change) and faithfully +apply those wipe-drops to their own data. The drops then ricochet through every node's own +re-emission, and even the freshly promoted primary applied the demoted node's wipe-drop right +at its promotion (its stopping ReplicationManager flushes queued events) - so whether a user +created on the new primary survived on any given node was pure timing (the +`StepdownReplicationTest` ~40% flake, and a real data-loss window on production failovers). +Initial-sync writes are now performed inside a new +`InMemoryDriver.suppressChangeStreamEvents()` scope - mirroring MongoDB, where initial-sync +writes are never oplogged - so the wipe + snapshot are invisible to change-stream watchers; +steady-state replication applies still emit events as before (a promoted secondary must be +able to serve resumable streams). + +#### Driver: failover read path could throw a raw NPE past every retry; stale `getLastConnectFailure()` after recovery +The read-preference fallback chain read the volatile `primaryNode` field multiple times; the +heartbeat nulls that field on stepdown or connection error - exactly while the fallback code +runs - so `hosts.get(null)` could throw a `NullPointerException` that, not being a +`MorphiumDriverException`, escaped every retry-catch on the read path and aborted a read the +fallback was built to save. Both fallback sites now work on a local snapshot. Additionally, +`getLastConnectFailure()` is cleared when a connect succeeds, so a caller polling after +recovery no longer sees the pre-recovery error as if it were current. + +#### InMemoryDriver: `updateUser` reset the user's SCRAM mechanism set on every password change; malformed field types escaped as ClassCastException +A password change without an explicit `mechanisms` field rebuilt the credentials with the +both-mechanisms default, silently re-arming SCRAM-SHA-1 for a user deliberately created +SHA-256-only; mongod preserves the existing mechanism set, and now the in-memory driver does +too. `mechanisms` without `pwd` is now supported with mongod's subset-only semantics (stored +credentials of the named mechanisms are kept verbatim, the rest dropped; non-subset requests +are `BadValue`). All optional fields are shape-checked before casting, so `roles: "foo"` &co. +produce a `BadValue` command error instead of an uncaught `ClassCastException`. + +#### PoppyDB: demoted leader could keep `primary==true` forever after a rapid leadership flap +`onLeadershipChange` incremented the leadership epoch and then wrote the `primary` flag +unsynchronized: a preempted stale dispatch could re-assert its outdated flag value AFTER a +newer transition had written the current one. A node stuck with `primary==true` as a follower +silently never replicates - `startReplicationToLeader`, the liveness probe and the retry chain +all no-op on `primary`. Epoch bump and flag flip are now one atomic unit, making a stale +overwrite structurally impossible. Related hardening in the same area: the post-start +replication liveness probe now checks "watch never registered" (`watchGeneration`) instead of +the instantaneous `isWatchLive()`, so it no longer tears down a healthy `ReplicationManager` +it happens to sample during a routine watch-reconnect gap; and a late election callback can no +longer install a `ReplicationManager` after `shutdown()` that nothing ever stops. + +#### PoppyDB: `rs.status()` reported a peer that died with the failover as SECONDARY forever +`becomeLeader()` clears the peer-contact map, and a peer with no contact entry was treated as +reachable indefinitely - so the classic crashed ex-primary, which never acks a single +heartbeat of the new leader, was never reported DOWN. A missing entry is now only treated as +reachable within a grace period (the heartbeat freshness window) measured from the moment +leadership was assumed; beyond that the peer reports `state: 8, stateStr: "DOWN"`. + +#### `startPoppyDB.sh`: "port already in use, skipping node" did not actually skip +The busy-port check printed the skip message but started the node anyway - the new JVM could +not bind, but its PID had already overwritten the running node's PID file, which the failure +branch then deleted, orphaning the still-running original process for `stop`/`status`. The +skip is now real (and keeps the port sequence of the remaining nodes intact). + +#### PoppyDB: `--auth`/`--ssl` now work on a replica set - the internal election/replication channel was always plaintext and unauthenticated +Each of `--auth` and `--ssl`, independently, made a multi-node PoppyDB replica set completely non-functional: `ElectionNetworkClient` (vote requests, heartbeats) and `ReplicationManager` (the sync connection to the primary) connected to peers as a plain, unauthenticated, unencrypted client, regardless of the server's own `--auth`/`--ssl` configuration. With `--ssl=true` every internal connection was rejected by the peer's TLS-only listener (`NotSslRecordException`); with `--auth=true` the election RPCs (`requestVote`/`appendEntries`) aren't on the pre-auth command whitelist, so every one was rejected as unauthorized - either way, no leader could ever be elected. Single-node PoppyDB with `--auth`/`--ssl` was unaffected; the client-facing enforcement itself was never the problem. The internal channel now authenticates as the configured root user and, when TLS is on, trusts exactly the server's own configured certificate (`ssl-keystore`, reused as the internal client's pinned truststore) - no new config keys, no change to auth enforcement. -### Fixed #### InMemoryDriver: `$sample` larger than the collection threw instead of returning all documents `$sample` cut its shuffled copy with `subList(0, size)`, so a sample size exceeding the collection count failed with `IndexOutOfBoundsException: toIndex = N` instead of returning all documents in random order like mongod. Visible in every mongosh session against PoppyDB: tab completion samples schema documents with `$sample {size: 10}`, so completing on any collection with fewer than 10 documents printed a `Tab completion error: ... aggregate failed: toIndex = 10` stack trace. @@ -240,7 +908,7 @@ The flush paths remove a type's buffer via `opLog.remove()` without holding the `Msg.sendAnswer` computed `deleteAt = now + getTtl()` **before** any TTL defaulting ran. An answer created via plain `new Msg()`/`new JMSMessage()` (ttl 0 — the JMS ack pattern) was therefore stored with `deleteAt = now`: the TTL sweeper raced the consumer for the freshly inserted document and won in roughly 1–5% of runs, deleting the answer between its change-stream event and the consumer's reread. The result was the long-hunted answer-timeout flaky (BasicJMSTests et al.) — persistent within a run, because the queued-for-processing marker also blocked the fallback poll from rescuing the vanished message. `sendAnswer` now leaves `deleteAt` unset when no TTL was chosen, so the send path applies `messagingDefaultTtl` first and `preStore` derives `deleteAt` from the *defaulted* TTL. Explicit answer TTLs behave as before. Root-caused via the new processing decision trace: `queued → dequeued → runnable started → reread returned null - message gone` told the whole story. #### InMemoryDriver/PoppyDB: creating a time-series collection now fails loudly (#262 interim) -`create` with a `timeseries` spec used to log a WARN and create a **plain** collection — a silent divergence: no `timeField` enforcement, no retention, `listCollections` reporting the wrong type. It now returns a proper command error (code 115 `CommandNotSupported`) over the wire and raises a `MorphiumDriverException` for embedded users. On the way, `CreateCommand.execute()` was switched from cursor-style reading to `readSingleAnswer` — mongod's create reply is a plain document, and the cursor path silently swallowed cursor-less replies (including error documents) on the in-memory connection. Real time-series support is tracked in #261 (API) and #262 (in-memory emulation), both scheduled for 6.4.0. +`create` with a `timeseries` spec used to log a WARN and create a **plain** collection — a silent divergence: no `timeField` enforcement, no retention, `listCollections` reporting the wrong type. It now returns a proper command error (code 115 `CommandNotSupported`) over the wire and raises a `MorphiumDriverException` for embedded users. On the way, `CreateCommand.execute()` was switched from cursor-style reading to `readSingleAnswer` — mongod's create reply is a plain document, and the cursor path silently swallowed cursor-less replies (including error documents) on the in-memory connection. Real time-series support is tracked in #261 (API) and #262 (in-memory emulation), both scheduled for 7.0.0. #### InMemoryDriver/PoppyDB: resumed change streams could deliver an event twice A watch resuming with `resumeAfter` registers its subscription *before* replaying the event history (the reverse order would lose events written between history snapshot and live stream). An event written exactly in that window was delivered twice — once by the asynchronous live dispatch to the already-registered subscription, once by the replay — and, because the live dispatch can overtake the replay, in arbitrary order. Resumed subscriptions now suppress exact duplicates by resume token (a bounded recent-token window; a monotonic guard would have turned the reordering into losses). Fresh watches have no replay and are unaffected — no overhead on the messaging path. Real MongoDB never had this problem (oplog-cursor resume is snapshot-consistent); morphium's own consumers (messaging, PoppyDB replication) were already idempotent, so this mainly protects custom `ChangeStreamListener`s running against InMemoryDriver/PoppyDB. @@ -251,12 +919,6 @@ A watch resuming with `resumeAfter` registers its subscription *before* replayin #### InMemoryDriver/PoppyDB: auth commands no longer pretend to succeed (#245) The entire server-side authentication surface — `saslStart`, X.509 `authenticate`, `createUser`, `createRole` — consisted of empty stubs that queued no result, which the command-dispatch machinery resolved to `{ok:1.0}`: every client "authenticated" successfully with any or no credentials, and `createUser`/`createRole` reported success while creating nothing. These commands now fail loudly (`AuthenticationFailed`/`NotImplemented` with an unmistakable message) until real SCRAM verification and a user/role store exist. InMemoryDriver/PoppyDB still perform **no** authentication — do not expose them to untrusted networks. -#### Driver: mid-message read timeouts desynchronized the wire stream -A socket timeout that struck after part of a reply had already been read (header consumed, body still in flight — likely under load) left the TCP stream misaligned, and the driver kept using it: `readNextMessage` retried the parse on the same stream, reading payload bytes as a message header (the `Illegal opcode ...` errors, whose "opcode" values decode to ASCII fragments of BSON field names), and returned `null` at its deadline while leaving the half-read connection open for the next pool borrower. Any command on any connection could be hit. `parseFromStream` now distinguishes a timeout at a message boundary (0 bytes consumed — still aligned, retryable as before) from a mid-message timeout, which is surfaced as a fatal network error; the connection is closed instead of retried or pooled. A deadline expiring without any reply also closes the connection now — a late reply would otherwise be delivered to the next borrower (`watch()` reads without `responseTo` verification). `ChangeStreamMonitor` additionally closes, rather than releases, its connection after errors that leave the stream state unknown (a reply without a cursor, unclassified failures); the pool discards closed connections and replaces them. - -#### Changestream: events written during a watch restart were lost; messaging could drop messages -When a change stream died and was re-established, a consumer that had not yet received any event had no resume token, so the new stream started at "now" — every document inserted during the retry gap was silently skipped. For messaging this meant lost messages (observed as a subscriber never seeing a broadcast that was sent ~200ms after its stream went down). `watch()` now captures the cursor's `postBatchResumeToken`, which real MongoDB includes in every reply — also for empty batches — and publishes its freshest token on the `WatchCommand` on every exit; `ChangeStreamMonitor` adopts it for the next attempt, so restarts resume where the dead stream stopped. Messaging additionally polls the affected topic (and the DM collection, and all topics for the shared lock monitor) once every time a watch is (re-)established, deterministically catching up on anything written while the stream was down. The messaging fallback poll, documented as running every second but effectively gated to every ~125 seconds by a tick counter, is time-based now and runs every 10 seconds as a pure safety net behind the event-driven catch-up. - #### InMemoryDriver: `store()` failed with a duplicate-key error when replacing an existing document `storeInternal` located the document to replace via `findByFieldValue`, which returns *copies*, while `CollectionIndexStore` removes index entries by *identity*. The copy never matched, so the old `_id` entry stayed in the index and the following insert reported `E11000 duplicate key` — the ordinary "find it, change it, store it back" round-trip threw for every existing document, and the failed store left the index holding an entry for an already-removed document. The previous document is now resolved through the `_id` index, which yields the live reference. Unnoticed until now because morphium's usual update path goes through `update()`, not `store()`. diff --git a/README.de.md b/README.de.md index 9b4bceaf6..761645bcb 100644 --- a/README.de.md +++ b/README.de.md @@ -1,4 +1,11 @@ -# Morphium 6.2.4 +# Morphium + +

+ + + Morphium + +

**Feature-reiches MongoDB ODM und Messaging-Framework für Java 21+** @@ -10,10 +17,13 @@ Morphium ist eine umfassende Datenschicht-Lösung für MongoDB mit: - ⚡ **Multi-Level Caching** mit automatischer Cluster-Synchronisation - 🔌 **Eigener MongoDB Wire-Protocol-Treiber** für direkte Kommunikation - 🧪 **In-Memory-Treiber** für schnelle Tests (deutlich weniger Latenz, kein MongoDB nötig) +- 🌱 **[PoppyDB](https://sboesebeck.github.io/morphium/poppydb/)** — MongoDB-kompatibler In-Memory-Server: Replica Sets, Auth/TLS, Messaging-Backend - 🎯 **JMS API (experimentell)** für standardbasiertes Messaging -- 🚀 **JDK 21** mit Virtual Threads für optimale Concurrency +- 🚀 **Java 21+** — moderne Sprachbasis (Pattern Matching, Sealed Types) [![Maven Central](https://img.shields.io/maven-central/v/de.caluga/morphium.svg)](https://search.maven.org/artifact/de.caluga/morphium) +[![Tests](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fsboesebeck%2Fmorphium%2Ftest-results%2Fbadges%2Ftests.json)](https://github.com/sboesebeck/morphium/releases) +[![Coverage](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fsboesebeck%2Fmorphium%2Ftest-results%2Fbadges%2Fcoverage.json)](https://github.com/sboesebeck/morphium/releases) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) ## 🎯 Warum Morphium? @@ -22,26 +32,199 @@ Morphium ist eine umfassende Datenschicht-Lösung für MongoDB mit: ### Schnellvergleich -| Feature | Morphium | Spring Data + RabbitMQ | Kafka | -|---------|----------|------------------------|-------| -| Infrastruktur | Nur MongoDB | MongoDB + RabbitMQ | MongoDB + Kafka | -| Setup-Komplexität | ⭐ Sehr niedrig | ⭐⭐⭐ Mittel | ⭐⭐⭐⭐⭐ Hoch | -| Nachrichten persistent | Standard | Optional | Standard | -| Nachrichtenpriorität | ✅ Ja | ✅ Ja | ❌ Nein | -| Distributed Locks | ✅ Ja | ❌ Nein | ❌ Nein | -| Durchsatz (interne Tests) | ~8K msg/s | 10K–50K msg/s | 100K+ msg/s | -| Betrieb | ⭐ Sehr einfach | ⭐⭐ Mittel | ⭐⭐⭐⭐ Komplex | +| Feature | Morphium | Morphium + PoppyDB | Spring Data + RabbitMQ | Kafka | +|---------|----------|--------------------|------------------------|-------| +| Infrastruktur | Nur MongoDB | **Keine** — eingebetteter Java-Server | MongoDB + RabbitMQ | MongoDB + Kafka | +| Setup-Komplexität | ⭐ Sehr niedrig | ⭐ Minimal (eine Dependency) | ⭐⭐⭐ Mittel | ⭐⭐⭐⭐⭐ Hoch | +| Nachrichten persistent | Standard | Snapshots (optional) | Optional | Standard | +| Nachrichtenpriorität | ✅ Ja | ✅ Ja | ✅ Ja | ❌ Nein | +| Distributed Locks | ✅ Ja | ✅ Ja | ❌ Nein | ❌ Nein | +| Durchsatz one-way Send→Empfang* | ~870 msg/s | ~770–2100 msg/s | 10K–50K msg/s | 100K+ msg/s | +| Round-Trip Request→Response (Ping-Pong)* | 89 msg/s | **223 msg/s (2,5×)** | — | — | +| Betrieb | ⭐ Sehr einfach | ⭐ Trivial (ein Prozess) | ⭐⭐ Mittel | ⭐⭐⭐⭐ Komplex | + +_* Alle Zahlen sind Richtwerte und hängen stark von Hardware und Workload ab; die +Morphium-Werte sind [gemessen](docs/v5-vs-v6-performance.md), die RabbitMQ-/Kafka-Spalten +nennen übliche Hersteller-/Community-Angaben. Die beiden Zeilen messen Unterschiedliches. +**One-way** zählt nur Send→Empfang (keine Verarbeitung, keine Antwort): ~870 msg/s gegen ein +3-Node-MongoDB-Replica-Set; PoppyDB läuft in-process und skaliert daher mit dem Host — +~770 msg/s auf einem kleinen 4-Core-CI-Host, ~2100 msg/s auf einer Laptop-CPU. **Round-Trip** +misst komplette Ping-Pongs (Request raus, Response zurück): 223 msg/s bei 4,5 ms Latenz gegen +PoppyDB vs. 89 msg/s bei 11,3 ms gegen das MongoDB-Replica-Set — 2,5-facher Durchsatz bei +weniger als halber Latenz, weil PoppyDB und Morphium Messaging aufeinander optimiert sind +(beide Seiten erkennen das Gegenüber). PoppyDBs Stärke ist die Latenz, nicht der rohe +One-way-Durchsatz auf knapper Hardware. Die Persistenz dort ist Snapshot-basiert, siehe die +[PoppyDB-Sektion](#-poppydb--mongodb-kompatibler-in-memory-server) unten._ + +_**Wie real sind Kafkas 100K+ — und wie groß ist die Lücke wirklich?** Wir haben beides auf +ein und derselben Laptop-Maschine gemessen (Apple M1 Max, Single-Node Kafka 4.1, ~200-Byte- +Payload, ein Consumer, end-to-end vom ersten Send bis zum letzten Empfang — derselbe Aufbau +wie unser +[One-way-Benchmark](poppydb/src/test/java/de/caluga/poppydb/MessagingOneWayThroughputBenchmark.java)). +Im Normalbetrieb — asynchrones Senden, Batching im Client — erreichte Kafka ~900K msg/s; +die 100K+-Spalte ist also real und auf moderner Hardware sogar konservativ. Zwingt man +Kafka aber in Morphiums Semantik, bei der jede Message synchron gesendet und einzeln vom +Broker bestätigt wird (4 Sender-Threads, `acks=all`), fällt Kafka auf ~8–10K msg/s vs. +~1.800 msg/s für Morphium+PoppyDB auf derselben Maschine — Faktor 4–5, nicht 100+. Kafkas +Spitzendurchsatz kommt fast vollständig daraus, tausende Records pro Netzwerk-Roundtrip zu +batchen (ohne Per-Message-Broker-Ack und standardmäßig ohne Per-Message-fsync — Durability +kommt aus der Replikation), nicht aus schnellerer Verarbeitung der einzelnen Message. +Morphium Messaging sendet bewusst jede Message als einzeln bestätigten Insert; die +verbleibenden 4–5× sind der Preis eines vollen ODM-Inserts (Object-Mapping, Wire-Protokoll, +Change-Stream-Dispatch) pro Message._ + +_**Wo genau bleiben Morphiums Kosten pro Message?** Auf derselben Maschine zerlegt: Ein +roher `morphium.insert` desselben Msg-Dokuments in PoppyDB schafft ~4.600 docs/s — 0,33 ms +pro Operation single-threaded, gleichauf mit Kafkas ~0,5 ms Request-Latenz; Wire-Protokoll +und Server sind also nicht das Problem. Ein aktiver Change-Stream-Watcher bringt das auf +~3.600 docs/s (Fanout, ~20 %), und der volle Messaging-Layer (Topic-Registry, +Listener-Dispatch, Processing-Queue) landet bei ~2.500–2.800 msg/s, sobald die JVM warm +ist — die ~1.800 msg/s oben sind ein Kaltstart-Wert. Der eigentliche Begrenzer ist die +Schreib-Parallelität: PoppyDBs In-Memory-Backend serialisiert Writes, der Roh-Durchsatz +sättigt daher bei ~4.600 Inserts/s, egal wie viele Sender-Threads man hinzufügt (1 Thread: +~3.100/s; ab 2: ~4.300–4.600/s). Per-Message-bestätigter Durchsatz auf dem Niveau von +Kafkas Synchron-Modus (~8–10K msg/s) ist das realistische Ziel künftiger +Server-Parallelisierung — nicht 100K+, die kein System ohne Batching erreicht._ + +## 🌱 PoppyDB — MongoDB-kompatibler In-Memory-Server + +

+ + + PoppyDB + +

+ +PoppyDB ist Morphiums Schwesterprodukt: ein In-Memory-Server, der das MongoDB Wire Protocol +spricht. Jeder Client kann sich verbinden — `mongosh`, Compass, PyMongo, die offiziellen +Treiber und natürlich Morphium. Startet in Millisekunden, braucht null Infrastruktur: kein +Docker, kein Testcontainers, keine MongoDB-Installation. + +- Wire Protocol, Change Streams, Aggregation Pipeline, Indizes, Transaktionen +- **Replica-Set-Emulation** mit echter Leader Election und automatischem Failover +- **SCRAM-Authentifizierung + TLS** (6.3.0) — `mongosh` loggt sich exakt wie gegen echtes MongoDB ein +- **Deklaratives User-Provisioning** (6.3.0) via `--users-file` — idempotent, repliziert, versions-geschützt +- **Snapshot-Persistenz** — periodische Dumps, automatisches Restore beim Start +- **Messaging-Backend** — serverseitige Optimierungen speziell für Morphium Messaging + +### How-to: Eingebettetes Test-Backend + +```xml + + de.caluga + poppydb + 6.3.1 + test + +``` + +```java +PoppyDB server = new PoppyDB(27017, "localhost", 100, 10); +server.start(); +// ... jeder MongoDB-Client kann sich jetzt mit localhost:27017 verbinden ... +server.shutdown(); +``` + +### How-to: Die CLI — eine Wegwerf-MongoDB für JEDE Test-Suite + +Der Embedded-Weg oben ist Java-only; das CLI-Jar funktioniert für jeden Stack. Ein einzelnes, +self-contained Jar von Maven Central (Classifier `cli`) — deine Python-/Node-/Go-/Rust- +Integrationstests bekommen in Millisekunden einen MongoDB-kompatiblen Server, kein +Docker-Image, kein Testcontainers, nichts zu installieren: + +```bash +curl -O https://repo1.maven.org/maven2/de/caluga/poppydb/6.3.1/poppydb-6.3.1-cli.jar + +# Start für einen Testlauf: --no-config hält den Lauf isoliert von einer +# versehentlichen ~/.config/poppydb/config auf Entwickler-Maschinen - gleiche +# Flags, gleiches Verhalten in der CI +java -jar poppydb-6.3.1-cli.jar --port 27017 --no-config +``` + +Test-Suite auf `mongodb://localhost:27017` zeigen lassen, Prozess danach beenden — der +Zustand ist weg (außer man will Persistenz, siehe unten). `--help` listet alle Optionen. + +Die CLI ist aber nicht nur ein Test-Werkzeug: **Als Messaging-Backend ist sie +production-ready** — genau dafür existieren PoppyDBs serverseitige +Messaging-Optimierungen. Mit Snapshot-Persistenz, Replica Set für HA und Auth/TLS (alles +unten) hat man einen stehenden Message Broker aus einem einzigen Jar. Ein genereller +MongoDB-*Ersatz* ist sie nur für Dev/Test — als dediziertes Backend für Morphium Messaging +ist sie die Empfehlung, siehe das +[Deployment-Playbook](docs/howtos/poppydb-deployment.md). + +### How-to: Standalone-Server mit Persistenz + +```bash +java -jar poppydb-6.3.1-cli.jar --port 27017 --dump-dir ./data --dump-interval 300 +``` + +Snapshots alle 5 Minuten, finaler Dump beim Shutdown, automatisches Restore beim nächsten +Start. Die Konfiguration kann auch in einer Properties-Datei liegen: `--cfg /etc/poppydb/config` +(vorab validieren mit `--check-config`, effektives Ergebnis inspizieren mit `--print-config`). -_* Richtwerte aus internen Messungen; tatsächliche Werte hängen von Hardware und Workload ab._ +### How-to: 3-Node-Replica-Set + +Ein Prozess pro Knoten, alle mit derselben Seed-Liste — die Wahl bestimmt den Primary, +Failover passiert automatisch: + +```bash +java -jar poppydb-6.3.1-cli.jar -p 17017 --rs-name myrs \ + --rs-seed host1:17017,host2:17017,host3:17017 --rs-priorities 100,50,50 +``` + +User (`admin.system.users`) replizieren über das Set — Logins überleben den Failover. + +### How-to: Authentifizierung + TLS (6.3.0) + +```bash +java -jar poppydb-cli.jar -p 27018 --auth --rootUser admin --rootPassword s3cr3t \ + --ssl --sslKeystore server.jks --sslKeystorePassword changeit + +mongosh "mongodb://admin:s3cr3t@localhost:27018/test?authSource=admin" +``` + +Für die deklarative Provisionierung eines ganzen User-Sets zeigt `--users-file` auf eine +JSON-Datei — bei jedem Leadership-Wechsel idempotent angewendet, per Version-Gate gegen +Rollback geschützt. + +### How-to: Message Queue ohne MongoDB + +Morphium Messaging läuft mit PoppyDB als Backend — eine vollwertige Message Queue (Topics, +exklusive Zustellung, Request/Response) mit einer einzigen Java-Dependency. Das ist ein +Produktions-Use-Case, kein Test-Trick: PoppyDB und Morphium Messaging sind aufeinander +optimiert, und eine Standalone-PoppyDB (CLI, mit Persistenz + Replica Set + Auth/TLS) ergibt +einen dedizierten Message Broker, ohne eine MongoDB zu betreiben: + +```java +PoppyDB server = new PoppyDB(27017, "localhost", 100, 10); +server.start(); + +try (Morphium morphium = new Morphium(cfg)) { // cfg zeigt auf localhost:27017 + MorphiumMessaging messaging = morphium.createMessaging(); + messaging.addListenerForTopic("orders", (mq, msg) -> { + System.out.println("Neue Bestellung: " + msg.getValue()); + return null; + }); + messaging.start(); +} +``` + +📖 **Vertiefung:** [Online-Doku](https://sboesebeck.github.io/morphium/poppydb/) · +[PoppyDB-Guide](docs/poppydb.md) · +[Production-Deployment-Playbook](docs/howtos/poppydb-deployment.md) · +[Migration von MongoDB](docs/howtos/migration-mongodb-to-poppydb.md) ## 📚 Dokumentation ### Schnellzugriff - **[Dokumentenportal](docs/index.md)** – Einstieg in sämtliche Guides - **[Überblick](docs/overview.md)** – Kernkonzepte, Quickstart, Kompatibilität +- **[Upgrade v6.2→v6.3](docs/howtos/migration-v6_2-to-v6_3.md)** – was sich in 6.3.x ändert - **[Upgrade v6.1→v6.2](docs/howtos/migration-v6_1-to-v6_2.md)** – Migrationsleitfaden für 6.2.x - **[Migration v5→v6](docs/howtos/migration-v5-to-v6.md)** – Schritt-für-Schritt-Anleitung - **[InMemory Driver Guide](docs/howtos/inmemory-driver.md)** – Features, Einschränkungen, Tests +- **[PoppyDB-Guide](docs/poppydb.md)** – der MongoDB-kompatible In-Memory-Server im Detail +- **[PoppyDB Deployment-Playbook](docs/howtos/poppydb-deployment.md)** – Config-File, Replica Sets, Auth/TLS in Produktion ### Weitere Ressourcen - Aggregationsbeispiele: `docs/howtos/aggregation-examples.md` @@ -50,31 +233,47 @@ _* Richtwerte aus internen Messungen; tatsächliche Werte hängen von Hardware u - Production-Deployment: `docs/production-deployment-guide.md` - Monitoring & Troubleshooting: `docs/monitoring-metrics-guide.md` +## 🚀 Neu in Version 6.3 + +### Zwei optionale Integrationsmodule +`morphium-jakarta-data` implementiert [Jakarta Data 1.0](https://jakarta.ee/specifications/data/1.0/) auf Basis von Morphiums Query-Engine — `@Repository`-Interfaces mit Query-Ableitung aus Methodennamen, JDQL über `@Query` (inklusive `GROUP BY`/`HAVING`, übersetzt in eine Aggregation-Pipeline), Offset- sowie Cursor-/Keyset-Pagination. `quarkus-morphium` setzt darauf auf und liefert die CDI-Integration: Config-Mapping, `@MorphiumTransactional`, Health-Checks, Dev Services, Dev UI, GraalVM-Native-Image-Support und Repository-Generierung zur Build-Zeit per Gizmo. Beide sind optional — der Core hängt von keinem der beiden ab, und `-DskipExtensions` erzeugt weiterhin einen reinen Core-Build. Siehe [Jakarta Data](docs/jakarta-data.md) und [Quarkus-Extension](docs/quarkus-extension.md). + +**Hinweis:** Die Quarkus-Extension ist von `io.quarkiverse.morphium:quarkus-morphium:1.2.0` nach `de.caluga:quarkus-morphium:6.3.0` umgezogen. Nur die Koordinaten — keine Paketumbenennungen, keine API-Änderungen. + +### DualChannelMessaging (Beta) +Eine dritte Messaging-Implementierung: die gewohnte einzelne Collection samt Cursor für Broadcast- und Topic-Verkehr, dazu eine eigene Collection pro Empfänger mit eigenem Cursor und Dispatcher-Thread für gerichtete Nachrichten und Antworten. Auswahl über `cfg.messagingSettings().setMessagingImplementation("DualChannelMessaging")`. Bewusst Beta — jenseits der Sättigung tauscht sie etwas Durchsatz gegen deutlich bessere Tail-Latenz. Siehe `docs/howtos/messaging-implementations.md`. + +> ⚠️ **Alle Messaging-Teilnehmer einer Queue müssen dieselbe Implementierung fahren.** Das galt schon immer für `SingleCollectionMessaging` und `MultiCollectionMessaging` und gilt genauso für `DualChannelMessaging`: Die Implementierungen verwenden unterschiedliche Collection-Layouts, eine Brücke dazwischen gibt es nicht. Eine Abweichung schlägt *still* fehl — ein Standard-Knoten, der auf die Antwort eines Dual-Channel-Responders wartet, läuft ewig in den Timeout, weil die Antwort in der DM-Collection des Anfragenden landet, die Standard nie liest. Alle Knoten gemeinsam umstellen und Request/Reply-Verkehr währenddessen leeren oder pausieren. + +### Messaging-Verbesserungen (alle Implementierungen) +Ein Datenbank-Roundtrip weniger pro nicht-exklusiver Nachricht (Verarbeitung direkt aus dem `fullDocument` des Change Streams), event-getriebene Zustellung von Requeue-Nachrichten, konfigurierbare Default-TTL und Fallback-Poll-Taktung, ein Fallback-Poll, der sich nach der Lebendigkeit des Change Streams richtet, und ein Trace der Verarbeitungsentscheidung zur Diagnose von Antwort-Timeouts. + +### PoppyDB: betreibbar, nicht nur startbar +Echte SCRAM-SHA-1-/SCRAM-SHA-256-Authentifizierung mit optionaler Durchsetzung (`--auth`), deklarative Benutzerprovisionierung aus einer Datei (`--users-file`) und Benutzer, die über das ReplicaSet replizieren, statt nur auf einem Knoten zu existieren. Konfigurationsdateien (`--cfg`, `--print-config`, `--check-config`) halten Secrets von der Kommandozeile fern, `--log-level` beendet die DEBUG-Flut, und eine DevOps-Kommandofläche ergänzt Live-`currentOp`/`killOp`, `rs.conf()`, `listCommands`, `hostInfo`, `dbHash` sowie ein `validate`, das die Indizes wirklich abläuft. + +### Speicher-Wasserstandsmarken und ehrliche Größenlimits +Zwei Heap-Marken (`--memory-warn` / `--memory-reject`, entschieden anhand des Live-Sets nach GC) lehnen dokumenterzeugende Schreibvorgänge mit einem wiederholbaren `ExceededMemoryLimit` ab, bevor der Heap stirbt — Updates, Deletes und TTL-Ablauf bleiben erlaubt, damit das System abfließen kann. Das 16-MB-BSON-Dokumentlimit wird jetzt wie bei mongod durchgesetzt statt nur angekündigt, und `maxMessageSizeBytes` wird durchgängig respektiert, inklusive byte-basierter Aufteilung von Schreib-Batches. + +### InMemoryDriver: der Abstand zu mongod schrumpft +Neue Aggregation-Stages (`$merge`, `$documents`, `$densify`, `$fill`, `$setWindowFields`, `$collStats`, `$listSessions` und ein echtes `$out`), rund 40 zusätzliche Expression-Operatoren, die Positions-Operatoren `$`/`$[]`/`$[]` mit `arrayFilters` sowie `$bit`. Dazu eine lange Liste von Korrektheitsfixes — darunter `$geoWithin` mit `$center`/`$centerSphere`/`$polygon`, das *jedes* Dokument traf, UTC-korrekte Datumsoperatoren mit 1-basiertem `$month` und ein `$project`-Inclusion-Modus, der die Ausgabe tatsächlich einschränkt. + +### Härtung von Replikation und Failover +PoppyDBs Replikation ist jetzt verlustfrei, reihenfolgetreu und umfasst Indexdefinitionen. Behoben: ein neu synchronisierendes Secondary, das seinen Initial-Sync-Wipe als Change-Stream-Drop-Events verbreitete (womit sich `admin.system.users` während eines Stepdowns clusterweit zerstören ließ), ein degradierter Leader, der bei `primary == true` hängen blieb, ein `rs.status()`, das einen toten Peer für immer als SECONDARY meldete, und ein unverschlüsselter interner Wahl-/Replikationskanal, der `--auth`/`--ssl` im ReplicaSet wirkungslos machte. Auf Client-Seite konnte der Failover-Lesepfad eine nackte NPE an jedem Retry vorbei werfen. + +### Performance +Die Duplikatsprüfung auf `_id` beim Insert ist ein O(1)-Indexzugriff statt eines vollständigen Scans unter dem Schreiblock, das Before-Image des Change Streams wird nicht mehr doppelt tief kopiert, und das Rebuild-Pingpong zwischen offener Transaktion und parallelen Lesern ist beseitigt. + +Das Upgrade beschreibt der [Migrationsleitfaden](docs/howtos/migration-v6_2-to-v6_3.md) Schritt für Schritt; alle Details stehen im [CHANGELOG](CHANGELOG.md). + ## 🚀 Neu in Version 6.2 ### Multi-Module Maven Build Morphium ist jetzt ein Multi-Module-Projekt: `morphium-parent` (BOM), `morphium` (Core-Bibliothek) und `poppydb` (Server). Die Core-Bibliothek `de.caluga:morphium` enthält keine Server-Abhängigkeiten (Netty etc.) mehr — ca. 90% schlanker für Nutzer, die nur das ODM benötigen. ### PoppyDB – Standalone MongoDB-kompatibler Server -Der ehemalige MorphiumServer ist jetzt ein eigenständiges Modul `de.caluga:poppydb`. Er implementiert das MongoDB Wire Protocol als In-Memory-Server mit Replica-Set-Emulation, Change Streams, Aggregation Pipeline und Snapshot-basierter Persistenz. - -PoppyDB und Morphium Messaging sind **aufeinander optimiert** — beide Seiten erkennen das Gegenüber und passen ihr Verhalten an. Das Ergebnis: niedrigere Latenz und weniger Overhead als mit einer echten MongoDB als Messaging-Backend. - -```xml - - de.caluga - poppydb - 6.2.4 - test - -``` - -- ✅ **Volle Wire-Protocol-Unterstützung**: Jeder MongoDB-Client kann sich verbinden (mongosh, Compass, PyMongo, ...) -- ✅ **Messaging-Backend**: Morphium-Messaging ohne MongoDB betreiben — optimiert für niedrige Latenz -- ✅ **CLI-Tooling**: `poppydb-6.2.4-cli.jar` für Standalone-Deployment -- ✅ **Replica-Set-Emulation**: Cluster-Verhalten testen ohne echtes MongoDB -- ✅ **Snapshot-Persistenz**: `--dump-dir` / `--dump-interval` zum Sichern der Daten über Neustarts -- ✅ **Opt-in-Authentifizierung & TLS** (6.3.0): Echtes SCRAM-SHA-1/-256 (`--auth`, `--rootUser`) plus SSL/TLS (`--ssl`) — Standard-Clients wie mongosh authentifizieren sich exakt wie gegen echtes MongoDB +Der ehemalige MorphiumServer wurde in 6.2 zum eigenständigen Modul `de.caluga:poppydb` — was +er kann und wie man ihn einsetzt, steht in der +[PoppyDB-Sektion oben](#-poppydb--mongodb-kompatibler-in-memory-server). ### MorphiumDriverException ist jetzt unchecked `MorphiumDriverException` erbt von `RuntimeException` — konsistent mit dem MongoDB Java Driver. Eliminiert 40+ Boilerplate `catch-wrap-rethrow`-Blöcke. @@ -91,8 +290,8 @@ Funktioniert korrekt mit `store()` und `storeList()`, unterstützt `@CreationTim ### CosmosDB Auto-Erkennung Morphium erkennt Azure CosmosDB-Verbindungen und passt sein Verhalten automatisch an. -### Patch-Releases 6.2.1 – 6.2.4 -Die 6.2.x-Patch-Releases brachten laufend Verbesserungen, unter anderem: serverseitiges Empfänger-Filtering und einen Liveness-Watchdog fürs Messaging, die neue Einstellung `defaultQueryTimeoutMS`, Feldnamen-Übersetzung in `Aggregator` und `Query.distinct()`, eine eigene `MorphiumDocumentTooLargeException` sowie zahlreiche Robustheits-Fixes für PoppyDB und den InMemoryDriver. +### Patch-Releases 6.2.1 – 6.2.10 +Die 6.2.x-Patch-Releases brachten laufend Verbesserungen, unter anderem: serverseitiges Empfänger-Filtering und einen Liveness-Watchdog fürs Messaging, die neue Einstellung `defaultQueryTimeoutMS`, Feldnamen-Übersetzung in `Aggregator` und `Query.distinct()`, eine eigene `MorphiumDocumentTooLargeException` sowie zahlreiche Robustheits-Fixes für PoppyDB und den InMemoryDriver. Die späteren Patches (6.2.5–6.2.10) konzentrierten sich auf Produktions-Härtung von Wire-Pfad und Messaging: Mid-Message-Read-Timeouts desynchronisieren den Wire-Stream nicht mehr, Antworten werden gegen ihre Request-ID (`responseTo`) verifiziert, Change Streams setzen nach Neustarts am letzten Token wieder auf statt Events zu überspringen, und exklusive Messages können bei mitten in der Verarbeitung verlorenem Lock nicht mehr doppelt verarbeitet werden. Siehe [CHANGELOG](CHANGELOG.md) für alle Details. @@ -120,7 +319,7 @@ public void doStuff() { ... } | | 6.1.x | 6.2.x | |---|---|---| -| Maven-Artifact | in `morphium` enthalten | separat: `de.caluga:poppydb:6.2.4` | +| Maven-Artifact | in `morphium` enthalten | separat: `de.caluga:poppydb:6.3.1` | | Package | `de.caluga.morphium.server` | `de.caluga.poppydb` | | Hauptklasse | `MorphiumServer` | `PoppyDB` | | CLI-JAR | `morphium-*-server-cli.jar` | `poppydb-*-cli.jar` | @@ -142,18 +341,16 @@ Detaillierte Anleitung: **[Migration v6.1→v6.2](docs/howtos/migration-v6_1-to- ## 🚀 Neu in Version 6.0 ### JDK 21 & Moderne Java-Features -- **Virtual Threads**: Messaging-System optimiert für Project Loom - **Pattern Matching**: Verbesserte Code-Klarheit und Typ-Sicherheit - **Records**: Noch nicht als `@Entity` oder `@Embedded` unterstützt (siehe [#116](https://github.com/sboesebeck/morphium/issues/116)) - **Sealed Classes**: Bessere Typ-Hierarchien in Domain-Models +- **Virtual Threads** wurden in dieser Ära eingeführt, aber in 6.2.x wieder ausgebaut: JDK 21s `synchronized`-Pinning führte unter Last zu Deadlocks. Morphium läuft durchgehend auf Plattform-Threads; eine Neubewertung ist geplant, sobald JEP 491 (JDK 24+) die Baseline ist. ### Treiber & Konnektivität - **SSL/TLS-Unterstützung**: Sichere Verbindungen zu MongoDB-Instanzen (seit v6.0) -- **Virtual Threads** im Treiber für optimale Performance ### Verbessertes Messaging-System - **Weniger Duplikate**: Optimierte Message-Processing-Logik -- **Virtual Thread Integration**: Bessere Concurrency-Performance - **Höherer Durchsatz**: Interne Benchmarks zeigen deutliche Steigerungen - **Distributed Locking**: Verbesserte Multi-Instance-Koordination @@ -199,7 +396,7 @@ Upgrade von v6.1? → `docs/howtos/migration-v6_1-to-v6_2.md` de.caluga morphium - 6.2.4 + 6.3.1 ``` @@ -382,13 +579,13 @@ PoppyDB (ehemals MorphiumServer) ist ein eigenständiger Prozess, der das MongoD ```bash # Server starten -java -jar poppydb/target/poppydb-6.2.4-cli.jar +java -jar poppydb/target/poppydb-6.3.1-cli.jar # Clients verbinden (z.B. MongoDB Compass, mongosh) mongosh mongodb://localhost:27017 # Start mit Persistenz (Snapshots) -java -jar poppydb/target/poppydb-6.2.4-cli.jar --dump-dir ./data --dump-interval 300 +java -jar poppydb/target/poppydb-6.3.1-cli.jar --dump-dir ./data --dump-interval 300 ``` **Replica Set Unterstützung (experimentell)** @@ -396,7 +593,7 @@ java -jar poppydb/target/poppydb-6.2.4-cli.jar --dump-dir ./data --dump-interval PoppyDB unterstützt eine grundlegende Replica-Set-Emulation. Starten Sie mehrere Instanzen mit demselben Replica-Set-Namen und derselben Seed-Liste: ```bash -java -jar poppydb/target/poppydb-6.2.4-cli.jar --rs-name my-rs --rs-seed host1:17017,host2:17018 +java -jar poppydb/target/poppydb-6.3.1-cli.jar --rs-name my-rs --rs-seed host1:17017,host2:17018 ``` **Use Cases:** @@ -470,6 +667,6 @@ Ein besonderer Dank geht an **Heiko Kopp** ([Bardioc1977](https://github.com/Bar **Upgrade geplant?** Siehe [Upgrade v6.1→v6.2](docs/howtos/migration-v6_1-to-v6_2.md) oder [Migration v5→v6](docs/howtos/migration-v5-to-v6.md). -Viel Erfolg mit Morphium 6.2.4! 🚀 +Viel Erfolg mit Morphium! 🚀 *Stephan Bösebeck & das Morphium-Team* diff --git a/README.md b/README.md index a79b9cf34..67d8823d6 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,11 @@ -# Morphium 6.2.4 +# Morphium + +

+ + + Morphium + +

**Feature-rich MongoDB ODM and messaging framework for Java 21+** @@ -9,36 +16,229 @@ Available languages: English and [Deutsch](README.de.md) - ⚡ **Multi-level caching** with cluster-wide invalidation - 🔌 **Custom MongoDB wire-protocol driver** tuned for Morphium - 🧪 **In-memory driver** for fast tests (no MongoDB required) +- 🌱 **[PoppyDB](https://sboesebeck.github.io/morphium/poppydb/)** — MongoDB-compatible in-memory server: replica sets, auth/TLS, messaging backend - 🎯 **JMS API (experimental)** for standards-based messaging -- 🚀 **Java 21** with virtual threads for optimal concurrency +- 🚀 **Java 21+** — modern language baseline (pattern matching, sealed types) [![Maven Central](https://img.shields.io/maven-central/v/de.caluga/morphium.svg)](https://search.maven.org/artifact/de.caluga/morphium) +[![Tests](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fsboesebeck%2Fmorphium%2Ftest-results%2Fbadges%2Ftests.json)](https://github.com/sboesebeck/morphium/releases) +[![Coverage](https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fsboesebeck%2Fmorphium%2Ftest-results%2Fbadges%2Fcoverage.json)](https://github.com/sboesebeck/morphium/releases) [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) ## 🎯 Why Morphium? Morphium is the only Java ODM that ships a message queue living inside MongoDB. If you already run MongoDB, you can power persistence, messaging, caching, and change streams with a single component. -| Feature | Morphium | Spring Data + RabbitMQ | Kafka | -|---------|----------|------------------------|-------| -| Infrastructure | MongoDB only | MongoDB + RabbitMQ | MongoDB + Kafka | -| Setup complexity | ⭐ Very low | ⭐⭐⭐ Medium | ⭐⭐⭐⭐⭐ High | -| Message persistence | Built in | Optional | Built in | -| Message priority | ✅ Yes | ✅ Yes | ❌ No | -| Distributed locks | ✅ Yes | ❌ No | ❌ No | -| Throughput (internal tests) | ~8K msg/s | 10K–50K msg/s | 100K+ msg/s | -| Operations | ⭐ Very easy | ⭐⭐ Medium | ⭐⭐⭐⭐ Complex | +| Feature | Morphium | Morphium + PoppyDB | Spring Data + RabbitMQ | Kafka | +|---------|----------|--------------------|------------------------|-------| +| Infrastructure | MongoDB only | **None** — embedded Java server | MongoDB + RabbitMQ | MongoDB + Kafka | +| Setup complexity | ⭐ Very low | ⭐ Minimal (one dependency) | ⭐⭐⭐ Medium | ⭐⭐⭐⭐⭐ High | +| Message persistence | Built in | Snapshots (optional) | Optional | Built in | +| Message priority | ✅ Yes | ✅ Yes | ✅ Yes | ❌ No | +| Distributed locks | ✅ Yes | ✅ Yes | ❌ No | ❌ No | +| Throughput, one-way send→receive* | ~870–1,250 msg/s | ~770–4,900 msg/s | 10K–50K msg/s | 100K+ msg/s | +| Round-trip request→response (ping-pong)* | 89 msg/s | **223 msg/s (2.5×)** | — | — | +| Operations | ⭐ Very easy | ⭐ Trivial (single process) | ⭐⭐ Medium | ⭐⭐⭐⭐ Complex | + +_* All numbers are indicative and depend heavily on hardware and workload; Morphium's are +[measured](docs/v5-vs-v6-performance.md), the RabbitMQ/Kafka columns quote typical vendor/ +community figures. The two rows measure different things. **One-way** counts send→receipt +only (no processing, no reply): ~870–1,250 msg/s against a 3-node MongoDB replica set +(depending on the client host); PoppyDB +runs in-process and therefore scales with the host — ~770 msg/s on a small 4-core CI host, +~2,100 msg/s on an M1 Max laptop, ~4,300–4,900 msg/s on an M1 Ultra desktop — in-process, +it simply scales with the host. **Round-trip** measures complete ping-pongs (request out, +response received): 223 msg/s at 4.5 ms latency against PoppyDB vs. 89 msg/s at 11.3 ms +against the MongoDB replica set — 2.5× the throughput at less than half the latency, thanks +to PoppyDB and Morphium Messaging being optimized for each other (both sides detect the +counterpart). Re-measured 2026-08-07 with the Morpheus load generator (100 msg/s fixed +rate, 5 sender threads, Mac Studio client): median round-trip 2.4 ms against a local +PoppyDB replica set vs 5.7 ms against the MongoDB replica set — note that this run was +*not* like-for-like (PoppyDB local, MongoDB over the network), so part of that gap is +network, not broker. A **symmetric re-measurement on 2026-08-11** — client inside the +homelab network, both backends separate processes on dedicated hosts at equal distance — +confirms the ratio at **2.34–2.49×**: MongoDB p50 4.97/5.12 ms vs PoppyDB p50 2.13/2.06 ms +over two runs (3001 pings each, zero loss). The tail is where they really diverge: MongoDB +p99 42–129 ms at 100 msg/s on an idle cluster, PoppyDB below 7 ms, with 2.5–3× lower jitter. +A same-session A/B attributes 8–18 % lower median RTT to the 2026-08 messaging +optimizations (answers dispatched before the `processed_by` write, non-exclusive messages +processed straight from the change-stream `fullDocument`). PoppyDB's strength is latency, +not raw one-way throughput on constrained hardware. Persistence there is snapshot-based, see the +[PoppyDB section](#-poppydb--mongodb-compatible-in-memory-server) below._ + +_**How real is Kafka's 100K+ figure — and how big is the gap really?** We measured both on +one and the same laptop-class machine (Apple M1 Max, single-node Kafka 4.1, ~200-byte +payload, one consumer, end-to-end from first send to last receipt — the same setup as our +[one-way benchmark](poppydb/src/test/java/de/caluga/poppydb/MessagingOneWayThroughputBenchmark.java)). +In its normal operating mode — asynchronous sends, client-side batching — Kafka reached +~900K msg/s, so the 100K+ column is real and even conservative on modern hardware. But +forced into Morphium's semantics, where every message is sent synchronously and individually +acknowledged by the broker (4 sender threads, `acks=all`), Kafka drops to ~8–10K msg/s vs. +~1,800 msg/s for Morphium+PoppyDB on the same machine — a factor of 4–5, not 100+. Kafka's +headline throughput comes almost entirely from batching thousands of records into each +network round-trip (with no per-message broker ack and, by default, no per-message fsync — +durability comes from replication), not from faster per-message handling. Morphium Messaging +deliberately sends each message as an individually acknowledged insert; the remaining 4–5× +is the price of a full ODM insert (object mapping, wire protocol, change-stream dispatch) +per message._ + +_**Where exactly does Morphium's per-message cost go?** Decomposed on the same machine: a +raw `morphium.insert` of the very same Msg document into PoppyDB runs at ~4,600 docs/s — +0.33 ms per operation single-threaded, on par with Kafka's ~0.5 ms per-request latency, so +the wire protocol and server are not the problem. An active change-stream watcher brings +that to ~3,600 docs/s (fanout, ~20 %), and the full messaging layer (topic registry, +listener dispatch, processing queue) lands at ~2,500–2,800 msg/s once the JVM is warm — the +~1,800 msg/s above is a cold-start figure. The 2026-08 optimization round (duplicate-`_id` +insert pre-check is an O(1) index lookup instead of an O(N) collection scan, one dead +messaging index removed, non-exclusive messages processed straight from the change-stream +`fullDocument` with no per-message re-read) additionally made insert cost independent of +collection size — the former O(N) `_id` scan degraded to double-digit inserts/s on a +200K-document collection, the index lookup holds >200K inserts/s there (A/B-measured on an +M1 Ultra); its effect on the M1-Max figures in this paragraph has not been re-measured yet. +The real limiting +factor is write concurrency: PoppyDB's in-memory backend serializes writes per collection, +so raw throughput plateaus at ~4,600 +inserts/s no matter how many sender threads you add (1 thread: ~3,100/s; 2+: ~4,300–4,600/s). +Per-message-acknowledged throughput on par with Kafka's synchronous mode (~8–10K msg/s) is +the realistic ceiling for future server-side concurrency work — not 100K+, which no system +reaches without batching._ + +## 🌱 PoppyDB — MongoDB-Compatible In-Memory Server + +

+ + + PoppyDB + +

+ +PoppyDB is Morphium's sibling product: an in-memory server that speaks the MongoDB wire +protocol. Any client connects — `mongosh`, Compass, PyMongo, the official drivers, and of +course Morphium. It starts in milliseconds and needs zero infrastructure: no Docker, no +Testcontainers, no MongoDB installation. + +- Wire protocol, change streams, aggregation pipeline, indexes, transactions +- **Replica-set emulation** with real leader election and automatic failover +- **SCRAM authentication + TLS** (6.3.0) — `mongosh` logs in exactly as against real MongoDB +- **Declarative user provisioning** (6.3.0) via `--users-file` — idempotent, replicated, version-gated +- **Snapshot persistence** — periodic dumps, automatic restore on startup +- **Messaging backend** — server-side optimizations specifically for Morphium Messaging + +### How-to: embedded test backend + +```xml + + de.caluga + poppydb + 6.3.1 + test + +``` + +```java +PoppyDB server = new PoppyDB(27017, "localhost", 100, 10); +server.start(); +// ... any MongoDB client can connect to localhost:27017 now ... +server.shutdown(); +``` + +### How-to: the CLI — a throwaway MongoDB for ANY test suite + +The embedded route above is Java-only; the CLI jar works for every stack. It is a single +self-contained jar from Maven Central (classifier `cli`) — your Python/Node/Go/Rust +integration tests get a MongoDB-compatible server in milliseconds, no Docker image, no +Testcontainers, nothing to install: + +```bash +curl -O https://repo1.maven.org/maven2/de/caluga/poppydb/6.3.1/poppydb-6.3.1-cli.jar + +# start for a test run: --no-config keeps it isolated from any stray +# ~/.config/poppydb/config on a developer machine - same flags, same behavior in CI +java -jar poppydb-6.3.1-cli.jar --port 27017 --no-config +``` + +Point your test suite at `mongodb://localhost:27017`, kill the process afterwards — state is +gone (unless you want persistence, see below). `--help` lists all options. + +The CLI is not just a test tool, though: **as a messaging backend it is production-ready** — +that is exactly what PoppyDB's server-side messaging optimizations are for. Run it with +snapshot persistence, a replica set for HA, and auth/TLS (all below), and you have a +standing message broker with a single jar. It is a general-purpose MongoDB *replacement* +only for dev/test — but for Morphium Messaging it is the recommended dedicated backend, see +the [deployment playbook](docs/howtos/poppydb-deployment.md). + +### How-to: standalone server with persistence + +```bash +java -jar poppydb-6.3.1-cli.jar --port 27017 --dump-dir ./data --dump-interval 300 +``` + +Snapshots every 5 minutes, final dump on shutdown, automatic restore on the next start. +Config can also live in a properties file: `--cfg /etc/poppydb/config` (validate it upfront +with `--check-config`, inspect the effective result with `--print-config`). + +### How-to: 3-node replica set + +One process per node, each with the same seed list — election picks the primary, failover is +automatic: + +```bash +java -jar poppydb-6.3.1-cli.jar -p 17017 --rs-name myrs \ + --rs-seed host1:17017,host2:17017,host3:17017 --rs-priorities 100,50,50 +``` + +Users (`admin.system.users`) replicate across the set, so logins survive failover. + +### How-to: authentication + TLS (6.3.0) -_* Numbers are indicative and depend heavily on hardware and workload._ +```bash +java -jar poppydb-cli.jar -p 27018 --auth --rootUser admin --rootPassword s3cr3t \ + --ssl --sslKeystore server.jks --sslKeystorePassword changeit + +mongosh "mongodb://admin:s3cr3t@localhost:27018/test?authSource=admin" +``` + +For provisioning a whole user set declaratively, point `--users-file` at a JSON file — applied +idempotently on every leadership change, protected against rollback by a version gate. + +### How-to: message queue without MongoDB + +Morphium Messaging runs on PoppyDB as its backend — a full message queue (topics, exclusive +delivery, request/response) with a single Java dependency. This is a production use case, +not a test trick: PoppyDB and Morphium Messaging are optimized for each other, and a +standalone PoppyDB (CLI, with persistence + replica set + auth/TLS) makes a dedicated +message broker without operating a MongoDB: + +```java +PoppyDB server = new PoppyDB(27017, "localhost", 100, 10); +server.start(); + +try (Morphium morphium = new Morphium(cfg)) { // cfg points at localhost:27017 + MorphiumMessaging messaging = morphium.createMessaging(); + messaging.addListenerForTopic("orders", (mq, msg) -> { + System.out.println("new order: " + msg.getValue()); + return null; + }); + messaging.start(); +} +``` + +📖 **Deep dives:** [Online documentation](https://sboesebeck.github.io/morphium/poppydb/) · +[PoppyDB guide](docs/poppydb.md) · +[Production deployment playbook](docs/howtos/poppydb-deployment.md) · +[Migrating from MongoDB](docs/howtos/migration-mongodb-to-poppydb.md) ## 📚 Documentation ### Quick access - **[Documentation hub](docs/index.md)** – entry point for all guides - **[Overview](docs/overview.md)** – core concepts, quick start, compatibility +- **[Upgrade v6.2→v6.3](docs/howtos/migration-v6_2-to-v6_3.md)** – what changes in 6.3.x - **[Upgrade v6.1→v6.2](docs/howtos/migration-v6_1-to-v6_2.md)** – migration checklist for 6.2.x - **[Migration v5→v6](docs/howtos/migration-v5-to-v6.md)** – step-by-step upgrade guide - **[InMemory Driver Guide](docs/howtos/inmemory-driver.md)** – capabilities, caveats, testing tips +- **[PoppyDB Guide](docs/poppydb.md)** – the MongoDB-compatible in-memory server in depth +- **[PoppyDB Deployment Playbook](docs/howtos/poppydb-deployment.md)** – config file, replica sets, auth/TLS in production - **[Optimistic Locking (`@Version`)](docs/howtos/optimistic-locking.md)** – prevent lost updates with `@Version` - **[SSL/TLS & MONGODB-X509](docs/ssl-tls.md)** – encrypted connections and certificate-based authentication @@ -49,31 +249,49 @@ _* Numbers are indicative and depend heavily on hardware and workload._ - Production deployment: `docs/production-deployment-guide.md` - Monitoring & troubleshooting: `docs/monitoring-metrics-guide.md` +## 🚀 What’s New in v6.3 + +### Two Optional Integration Modules +`morphium-jakarta-data` implements [Jakarta Data 1.0](https://jakarta.ee/specifications/data/1.0/) on top of Morphium's query engine — `@Repository` interfaces with query derivation from method names, JDQL via `@Query` (including `GROUP BY`/`HAVING` compiled into an aggregation pipeline), offset and cursor/keyset pagination. `quarkus-morphium` builds on it for CDI integration: config mapping, `@MorphiumTransactional`, health checks, Dev Services, Dev UI, GraalVM native-image support, and build-time repository generation via Gizmo. Both are optional — core has no dependency on either, and `-DskipExtensions` still produces a core-only build. See [Jakarta Data](docs/jakarta-data.md) and [Quarkus Extension](docs/quarkus-extension.md). + +**Note:** the Quarkus extension moved from `io.quarkiverse.morphium:quarkus-morphium:1.2.0` to `de.caluga:quarkus-morphium:6.3.0`. Coordinates only — no package renames, no API changes. + +### DualChannelMessaging (beta) +A third messaging implementation: the standard single collection and cursor for broadcast/topic traffic, plus a dedicated per-recipient collection with its own cursor and dispatcher thread for directed messages and answers. Select it with `cfg.messagingSettings().setMessagingImplementation("DualChannelMessaging")`. Beta on purpose — past saturation it trades a little throughput for markedly better tail latency. See `docs/howtos/messaging-implementations.md`. + +> ⚠️ **All messaging participants on a queue must run the same implementation.** This has always been true for `SingleCollectionMessaging` and `MultiCollectionMessaging`, and it applies to `DualChannelMessaging` too: the implementations use different collection layouts and there is no bridge between them. A mismatch fails *silently* — a Standard node waiting for an answer from a Dual Channel responder times out forever, because the answer goes into the requester's DM collection, which Standard never reads. Switch every node together, and drain or pause request/reply traffic while you do. +> +> Since **6.3.1** a mismatch is *detected*: every instance announces its implementation in a layout-independent `_participants` collection and checks the other participants on startup — WARN by default; `cfg.messagingSettings().setMessagingImplementationCheck(ImplementationCheck.THROW)` makes a mismatched instance refuse to start instead (#280). + +### Messaging Improvements (all implementations) +One database roundtrip less per non-exclusive message (processed straight from the change-stream `fullDocument`), event-driven delivery of requeued messages, configurable default TTL and fallback-poll cadence, change-stream liveness driving the fallback poll, and a processing decision trace for diagnosing answer timeouts. + +### PoppyDB: Operable, Not Just Runnable +Real SCRAM-SHA-1/SCRAM-SHA-256 authentication with opt-in enforcement (`--auth`), declarative user provisioning from a file (`--users-file`) and users that replicate across the replica set instead of living on one node. Configuration files (`--cfg`, `--print-config`, `--check-config`) keep secrets off the command line, `--log-level` stops the DEBUG firehose, and a DevOps command surface adds live `currentOp`/`killOp`, `rs.conf()`, `listCommands`, `hostInfo`, `dbHash` and a `validate` that really walks the indexes. + +### Memory Watermark and Honest Size Limits +Two heap watermarks (`--memory-warn` / `--memory-reject`, decided on the post-GC live set) reject document-creating writes with a retryable `ExceededMemoryLimit` before the heap dies, while updates, deletes and TTL expiry stay allowed so the system can drain. The 16MB BSON document limit is now enforced like mongod instead of merely advertised, and `maxMessageSizeBytes` is respected end-to-end with byte-aware write-batch splitting. + +### InMemoryDriver: Closing the Gap to mongod +New aggregation stages (`$merge`, `$documents`, `$densify`, `$fill`, `$setWindowFields`, `$collStats`, `$listSessions`, and a real `$out`), ~40 additional expression operators, positional update operators `$`/`$[]`/`$[]` with `arrayFilters`, and `$bit`. Plus a long list of correctness fixes — among them `$geoWithin` with `$center`/`$centerSphere`/`$polygon`, which matched *every* document, UTC-correct date operators with a 1-based `$month`, and `$project` inclusion mode actually restricting output. + +### Replication and Failover Hardening +PoppyDB replication is now lossless, order-preserving and covers index definitions. Fixed: a re-syncing secondary broadcasting its initial-sync wipe as change-stream drop events (which could destroy `admin.system.users` cluster-wide during a stepdown), a demoted leader stuck at `primary == true`, `rs.status()` reporting a dead peer as SECONDARY forever, and a plaintext internal election/replication channel that made `--auth`/`--ssl` ineffective on a replica set. On the client side, the failover read path could throw a raw NPE past every retry. + +### Performance +Insert's duplicate-`_id` pre-check is an O(1) index lookup instead of a full scan under the write lock, the change-stream before-image is no longer deep-copied twice per watched update, and the index-store rebuild ping-pong between an open transaction and concurrent readers is gone. + +Upgrading is covered step by step in the [migration guide](docs/howtos/migration-v6_2-to-v6_3.md); see [CHANGELOG](CHANGELOG.md) for full details. + ## 🚀 What’s New in v6.2 ### Multi-Module Maven Build Morphium is now a multi-module project: `morphium-parent` (BOM), `morphium` (core library), and `poppydb` (server). The core library `de.caluga:morphium` no longer drags in server dependencies (Netty, etc.) — 90% leaner for users who just need the ODM. ### PoppyDB – Standalone MongoDB-Compatible Server -The former MorphiumServer is now an independent module `de.caluga:poppydb`. It implements the MongoDB Wire Protocol as an in-memory server with Replica Set emulation, Change Streams, Aggregation Pipeline, and snapshot-based persistence. - -PoppyDB and Morphium Messaging are **optimized for each other** — both sides recognize the counterpart and adapt their behavior, resulting in lower latency and less overhead than with a real MongoDB as messaging backend. - -```xml - - de.caluga - poppydb - 6.2.4 - test - -``` - -- ✅ **Full Wire Protocol**: Any MongoDB client can connect (mongosh, Compass, PyMongo, ...) -- ✅ **Messaging Backend**: Run Morphium messaging without MongoDB — optimized for low-latency -- ✅ **CLI Tooling**: `poppydb-6.2.4-cli.jar` for standalone deployment -- ✅ **Replica Set Emulation**: Test cluster behavior without real MongoDB -- ✅ **Snapshot Persistence**: `--dump-dir` / `--dump-interval` to preserve data across restarts -- ✅ **Opt-in Authentication & TLS** (6.3.0): Real SCRAM-SHA-1/-256 auth (`--auth`, `--rootUser`) plus SSL/TLS (`--ssl`) — standard clients like mongosh authenticate exactly as against real MongoDB +The former MorphiumServer became an independent module `de.caluga:poppydb` in 6.2 — see the +[PoppyDB section above](#-poppydb--mongodb-compatible-in-memory-server) for what it does and +how to use it. ### MorphiumDriverException is now unchecked `MorphiumDriverException` extends `RuntimeException` — consistent with the MongoDB Java driver. Eliminates 40+ boilerplate `catch-wrap-rethrow` blocks. @@ -90,8 +308,8 @@ Works correctly with `store()` and `storeList()`, supports `@CreationTime` on `D ### CosmosDB Auto-Detection Morphium detects Azure CosmosDB connections and automatically adjusts behavior for compatibility. -### Patch releases 6.2.1 – 6.2.4 -The 6.2.x patch releases brought continuous improvements, among them: server-side recipient filtering and a liveness watchdog for messaging, a `defaultQueryTimeoutMS` setting, field-name translation in `Aggregator` and `Query.distinct()`, a dedicated `MorphiumDocumentTooLargeException`, and numerous PoppyDB/InMemoryDriver robustness fixes. +### Patch releases 6.2.1 – 6.2.10 +The 6.2.x patch releases brought continuous improvements, among them: server-side recipient filtering and a liveness watchdog for messaging, a `defaultQueryTimeoutMS` setting, field-name translation in `Aggregator` and `Query.distinct()`, a dedicated `MorphiumDocumentTooLargeException`, and numerous PoppyDB/InMemoryDriver robustness fixes. The later patches (6.2.5–6.2.10) focused on production hardening of the wire path and messaging: mid-message read timeouts no longer desynchronize the wire stream, replies are verified against their request id (`responseTo`), change streams resume from the last token across restarts instead of silently skipping events, and exclusive messages can no longer be processed twice when their lock is lost mid-processing. See [CHANGELOG](CHANGELOG.md) for full details. @@ -124,7 +342,7 @@ The embedded MongoDB-compatible server was extracted to its own module and renam | | 6.1.x | 6.2.x | |---|---|---| -| Maven artifact | included in `morphium` | separate: `de.caluga:poppydb:6.2.4` | +| Maven artifact | included in `morphium` | separate: `de.caluga:poppydb:6.3.1` | | Package | `de.caluga.morphium.server` | `de.caluga.poppydb` | | Main class | `MorphiumServer` | `PoppyDB` | | CLI JAR | `morphium-*-server-cli.jar` | `poppydb-*-cli.jar` | @@ -135,7 +353,7 @@ If you use PoppyDB in tests, add the dependency: de.caluga poppydb - 6.2.4 + 6.3.1 test ``` @@ -185,18 +403,16 @@ Prevents lost updates in concurrent environments without requiring pessimistic d ## 🚀 What’s New in v6.0 ### Java 21 & Modern Language Features -- **Virtual threads** for high-throughput messaging and change streams - **Pattern matching** across driver and mapping layers - **Records**: Not yet supported as `@Entity` or `@Embedded` types (see [#116](https://github.com/sboesebeck/morphium/issues/116)) - **Sealed class support** for cleaner domain models +- **Virtual threads** were introduced in this era but rolled back again in 6.2.x: JDK 21's `synchronized` pinning caused deadlocks under load. Morphium runs on platform threads throughout; virtual threads will be re-evaluated once JEP 491 (JDK 24+) is the baseline. ### Driver & Connectivity - **SSL/TLS Support**: Secure connections to MongoDB instances (added in v6.0) -- **Virtual threads** in the driver for optimal concurrency ### Messaging Improvements - **Fewer duplicates** thanks to refined message processing -- **Virtual-thread integration** for smoother concurrency - **Higher throughput** confirmed in internal benchmarking - **Distributed locking** for coordinated multi-instance deployments @@ -245,7 +461,7 @@ Migrating from v5? → `docs/howtos/migration-v5-to-v6.md` de.caluga morphium - 6.2.4 + 6.3.1 ``` @@ -465,7 +681,7 @@ PoppyDB (formerly MorphiumServer) runs the Morphium wire-protocol driver in a se de.caluga poppydb - 6.2.4 + 6.3.1 ``` @@ -475,19 +691,19 @@ PoppyDB (formerly MorphiumServer) runs the Morphium wire-protocol driver in a se mvn clean package -pl poppydb -am -Dmaven.test.skip=true ``` -This creates `poppydb/target/poppydb-6.2.4-cli.jar`. +This creates `poppydb/target/poppydb-6.3.1-cli.jar`. **Running the Server** ```bash # Start the server on the default port (17017) -java -jar poppydb/target/poppydb-6.2.4-cli.jar +java -jar poppydb/target/poppydb-6.3.1-cli.jar # Start on a different port -java -jar poppydb/target/poppydb-6.2.4-cli.jar --port 8080 +java -jar poppydb/target/poppydb-6.3.1-cli.jar --port 8080 # Start with persistence (snapshots) -java -jar poppydb/target/poppydb-6.2.4-cli.jar --dump-dir ./data --dump-interval 300 +java -jar poppydb/target/poppydb-6.3.1-cli.jar --dump-dir ./data --dump-interval 300 ``` **Replica Set Support (Experimental)** @@ -495,7 +711,7 @@ java -jar poppydb/target/poppydb-6.2.4-cli.jar --dump-dir ./data --dump-interval PoppyDB supports basic replica set emulation. Start multiple instances with the same replica set name and seed list: ```bash -java -jar poppydb/target/poppydb-6.2.4-cli.jar --rs-name my-rs --rs-seed host1:17017,host2:17018 +java -jar poppydb/target/poppydb-6.3.1-cli.jar --rs-name my-rs --rs-seed host1:17017,host2:17018 ``` **Use cases** @@ -567,6 +783,6 @@ A special thank-you goes to **Heiko Kopp** ([Bardioc1977](https://github.com/Bar **Planning an upgrade?** Follow the [migration guide](docs/howtos/migration-v5-to-v6.md). -Enjoy Morphium 6.2.4! 🚀 +Enjoy Morphium! 🚀 *Stephan Bösebeck & the Morphium team* diff --git a/branding/brand-board.png b/branding/brand-board.png new file mode 100644 index 000000000..878f96ea4 Binary files /dev/null and b/branding/brand-board.png differ diff --git a/branding/brand-board.svg b/branding/brand-board.svg new file mode 100644 index 000000000..acc611095 --- /dev/null +++ b/branding/brand-board.svg @@ -0,0 +1,40 @@ + + Morphium and PoppyDB brand board + A presentation of the related Morphium and PoppyDB logo system with icons and colors. + + OPEN-SOURCE DATA INFRASTRUCTURE + + + + + + + + Morphium + Object mapping, without friction. + + + + + + + + + + + + + + Poppy + DB + Small, resilient, and built to replicate. + + + + + + + + Midnight · Violet · Poppy · Amber + + diff --git a/branding/morphium-logo-dark.svg b/branding/morphium-logo-dark.svg new file mode 100644 index 000000000..95a12e895 --- /dev/null +++ b/branding/morphium-logo-dark.svg @@ -0,0 +1,10 @@ + + Morphium logo (dark backgrounds) + An interlocking M symbol representing bidirectional object mapping, followed by the Morphium wordmark. Variant for dark backgrounds. + + + + + + Morphium + diff --git a/branding/morphium-logo.png b/branding/morphium-logo.png new file mode 100644 index 000000000..f7b85ae61 Binary files /dev/null and b/branding/morphium-logo.png differ diff --git a/branding/morphium-logo.svg b/branding/morphium-logo.svg new file mode 100644 index 000000000..42f742fcf --- /dev/null +++ b/branding/morphium-logo.svg @@ -0,0 +1,10 @@ + + Morphium logo + An interlocking M symbol representing bidirectional object mapping, followed by the Morphium wordmark. + + + + + + Morphium + diff --git a/branding/morphium-mark.png b/branding/morphium-mark.png new file mode 100644 index 000000000..ab646a1ee Binary files /dev/null and b/branding/morphium-mark.png differ diff --git a/branding/morphium-mark.svg b/branding/morphium-mark.svg new file mode 100644 index 000000000..8680480a5 --- /dev/null +++ b/branding/morphium-mark.svg @@ -0,0 +1,7 @@ + + Morphium mark + An interlocking M symbol representing bidirectional object mapping. + + + + diff --git a/branding/poppydb-logo-dark.svg b/branding/poppydb-logo-dark.svg new file mode 100644 index 000000000..85ce5ed60 --- /dev/null +++ b/branding/poppydb-logo-dark.svg @@ -0,0 +1,14 @@ + + PoppyDB logo (dark backgrounds) + A geometric four-petal poppy made from replicated data forms, followed by the PoppyDB wordmark. Variant for dark backgrounds. + + + + + + + + + Poppy + DB + diff --git a/branding/poppydb-logo.png b/branding/poppydb-logo.png new file mode 100644 index 000000000..531de0252 Binary files /dev/null and b/branding/poppydb-logo.png differ diff --git a/branding/poppydb-logo.svg b/branding/poppydb-logo.svg new file mode 100644 index 000000000..5786ed7d7 --- /dev/null +++ b/branding/poppydb-logo.svg @@ -0,0 +1,14 @@ + + PoppyDB logo + A geometric four-petal poppy made from replicated data forms, followed by the PoppyDB wordmark. + + + + + + + + + Poppy + DB + diff --git a/branding/poppydb-mark.png b/branding/poppydb-mark.png new file mode 100644 index 000000000..d27b57ad7 Binary files /dev/null and b/branding/poppydb-mark.png differ diff --git a/branding/poppydb-mark.svg b/branding/poppydb-mark.svg new file mode 100644 index 000000000..c881b2010 --- /dev/null +++ b/branding/poppydb-mark.svg @@ -0,0 +1,10 @@ + + PoppyDB mark + A geometric four-petal poppy representing replicated data. + + + + + + + diff --git a/docs/architecture-overview.md b/docs/architecture-overview.md index 6ca2aed55..7c83b3bab 100644 --- a/docs/architecture-overview.md +++ b/docs/architecture-overview.md @@ -161,10 +161,12 @@ Aggregator agg = morphium.createAggregator(Order.class, Order #### Cache Synchronization **Cluster-aware caching** with synchronization: -- **WatchingCacheSynchronizer** - Uses MongoDB Change Streams -- **MessagingCacheSynchronizer** - Uses Morphium messaging +- **WatchingCacheSynchronizer** - Watches the underlying collections via MongoDB Change Streams; catches changes from any writer (not just Morphium), but needs a replica set +- **MessagingCacheSynchronizer** - Propagates invalidations via Morphium's own messaging; works on any backend (no replica set needed), but only sees writes made through Morphium - **Manual cache control** for custom strategies +See [Developer Guide § Cache Synchronization](./developer-guide.md#cache-synchronization) for guidance on choosing between them. + #### Cache Strategies ```java @Cache( diff --git a/docs/assets/brand/morphium-logo-dark.svg b/docs/assets/brand/morphium-logo-dark.svg new file mode 100644 index 000000000..95a12e895 --- /dev/null +++ b/docs/assets/brand/morphium-logo-dark.svg @@ -0,0 +1,10 @@ + + Morphium logo (dark backgrounds) + An interlocking M symbol representing bidirectional object mapping, followed by the Morphium wordmark. Variant for dark backgrounds. + + + + + + Morphium + diff --git a/docs/assets/brand/morphium-logo.svg b/docs/assets/brand/morphium-logo.svg new file mode 100644 index 000000000..42f742fcf --- /dev/null +++ b/docs/assets/brand/morphium-logo.svg @@ -0,0 +1,10 @@ + + Morphium logo + An interlocking M symbol representing bidirectional object mapping, followed by the Morphium wordmark. + + + + + + Morphium + diff --git a/docs/assets/brand/morphium-mark-header.svg b/docs/assets/brand/morphium-mark-header.svg new file mode 100644 index 000000000..82bc12a5a --- /dev/null +++ b/docs/assets/brand/morphium-mark-header.svg @@ -0,0 +1,7 @@ + + Morphium mark + A light interlocking M symbol for the documentation header. + + + + diff --git a/docs/assets/brand/morphium-mark.svg b/docs/assets/brand/morphium-mark.svg new file mode 100644 index 000000000..8680480a5 --- /dev/null +++ b/docs/assets/brand/morphium-mark.svg @@ -0,0 +1,7 @@ + + Morphium mark + An interlocking M symbol representing bidirectional object mapping. + + + + diff --git a/docs/assets/brand/poppydb-logo-dark.svg b/docs/assets/brand/poppydb-logo-dark.svg new file mode 100644 index 000000000..85ce5ed60 --- /dev/null +++ b/docs/assets/brand/poppydb-logo-dark.svg @@ -0,0 +1,14 @@ + + PoppyDB logo (dark backgrounds) + A geometric four-petal poppy made from replicated data forms, followed by the PoppyDB wordmark. Variant for dark backgrounds. + + + + + + + + + Poppy + DB + diff --git a/docs/assets/brand/poppydb-logo.svg b/docs/assets/brand/poppydb-logo.svg new file mode 100644 index 000000000..5786ed7d7 --- /dev/null +++ b/docs/assets/brand/poppydb-logo.svg @@ -0,0 +1,14 @@ + + PoppyDB logo + A geometric four-petal poppy made from replicated data forms, followed by the PoppyDB wordmark. + + + + + + + + + Poppy + DB + diff --git a/docs/assets/brand/poppydb-mark.svg b/docs/assets/brand/poppydb-mark.svg new file mode 100644 index 000000000..c881b2010 --- /dev/null +++ b/docs/assets/brand/poppydb-mark.svg @@ -0,0 +1,10 @@ + + PoppyDB mark + A geometric four-petal poppy representing replicated data. + + + + + + + diff --git a/docs/assets/extra.css b/docs/assets/extra.css new file mode 100644 index 000000000..6c4274727 --- /dev/null +++ b/docs/assets/extra.css @@ -0,0 +1,13 @@ +/* Swap brand logos with the Material color scheme toggle. + Two tags are emitted per logo; exactly one is visible per scheme. */ +.logo-dark { + display: none; +} + +[data-md-color-scheme="slate"] .logo-light { + display: none; +} + +[data-md-color-scheme="slate"] .logo-dark { + display: inline; +} diff --git a/docs/developer-guide.md b/docs/developer-guide.md index fa67a36ab..da68a2371 100644 --- a/docs/developer-guide.md +++ b/docs/developer-guide.md @@ -232,24 +232,41 @@ See How‑To: [Aggregation Examples](./howtos/aggregation-examples.md) for more ## Caching - Add `@Cache` to entities to enable read cache; TTL, max entries, and clear strategy are configurable. -- Cluster‑wide cache synchronization uses Morphium’s messaging; see the [Messaging](./messaging.md) guide. +- Cluster‑wide cache synchronization comes in two flavors — messaging‑driven and watching (change‑stream‑driven); see below for which one to pick. - A JCache adapter is available if you prefer standard javax.cache interfaces. See How‑To: [Caching Examples](./howtos/caching-examples.md) and [Cache Patterns](./howtos/cache-patterns.md) for recipes and guidance. ### Cache Synchronization -- Purpose: keep caches consistent across nodes. Messaging was originally introduced to propagate cache change events in clusters. -- Mechanism: on writes, Morphium emits a cache message; other nodes apply a policy from `@Cache.syncCache`: +- Purpose: keep caches consistent across nodes when the underlying data changes. +- Mechanism: on a relevant change, other nodes apply a policy from `@Cache.syncCache`: - `CLEAR_TYPE_CACHE`: clear the entire type cache for the entity. - `REMOVE_ENTRY_FROM_TYPE_CACHE`: remove a single entry (by ID) from the cache. - `UPDATE_ENTRY`: re‑read and update the cached entity in place (may briefly expose stale data under concurrent reads—“dirty reads”). -- Requirements: ensure messaging is running on all nodes; change streams improve responsiveness and reduce polling (replica set required). -- Setup snippet: -```java -var messaging = morphium.createMessaging(); -messaging.start(); -new MessagingCacheSynchronizer(messaging, morphium); // attach synchronizer -``` +- Two independent implementations both drive the same `@Cache.syncCache` policies — pick one, not both, per Morphium instance: + + **`MessagingCacheSynchronizer`** — hooks into Morphium's own storage listener. Every `store`/`remove`/`update`/`drop` made *through this (or another) Morphium instance* triggers a `cacheSync` message over Morphium's own messaging, which every other node with a `MessagingCacheSynchronizer` attached picks up. + ```java + var messaging = morphium.createMessaging(); + messaging.start(); + new MessagingCacheSynchronizer(messaging, morphium); // attach synchronizer + ``` + - Requirements: messaging must be running (and healthy) on every node that should invalidate its cache. Works against any driver/backend — in‑memory, single MongoDB, PoppyDB, or a replica set — no change‑stream support needed. + - Blind spot: it only ever sees writes that go through Morphium's storage listener. Changes made by another application, a raw driver/shell write, a restore, or an admin script are invisible to it and will **not** invalidate remote caches. + - Extra strength: this is more than "just a watcher" — the invalidation travels as a regular message on the `cacheSyncType`/`cacheSyncRecord` topics of Morphium's general‑purpose messaging, not a raw DB event. Any node — including a non‑Java, non‑Morphium process that merely understands the message document format — can register its own listener on the same topic and trigger additional logic beyond the built‑in `@Cache.syncCache` handling (e.g. invalidate a different cache layer, fire a notification, write an audit trail). Worth it whenever the desired reaction to a cache‑relevant write is more involved than clear/remove/update. + + **`WatchingCacheSynchronizer`** — opens a MongoDB Change Stream (`ChangeStreamMonitor`) directly on the watched collections and reacts to whatever it sees change at the database layer, no matter who wrote it. + ```java + new WatchingCacheSynchronizer(morphium); // no messaging required + ``` + - Requirements: Change Streams need a replica set (same restriction as `messagingSettings().setUseChangeStream(true)`, see [Messaging](./messaging.md)) — it does **not** work against a standalone/single MongoDB node. Does not need Morphium's messaging running at all. + - Strength: catches every change to the underlying collection regardless of the writer — other services, migrations, mongosh, etc. — because it watches the data, not Morphium's write path. + +- **Which one to use:** + - Only Morphium instances ever write the cached collections, and you're on a standalone/single‑node backend without replica‑set/Change‑Stream support (e.g. a lone MongoDB, or you don't want the extra long‑lived watch connection): use `MessagingCacheSynchronizer`. + - Cache invalidation needs to be more than the built‑in clear/remove/update policies — e.g. other nodes (possibly non‑Java) should react to the same event with custom logic: use `MessagingCacheSynchronizer`. Its messages are just documents on a known topic, so anything that can read that topic can hook in, not only Morphium instances running `WatchingCacheSynchronizer`. + - Other processes/services (not just this Morphium cluster) can write the cached collections directly, or you'd rather not depend on messaging being up on every node, and you have a replica set: use `WatchingCacheSynchronizer` — it invalidates on *any* write to the collection, not just ones that went through Morphium's own listener. + - Both can run in parallel if you want belt‑and‑suspenders coverage, but that's usually unnecessary — pick the one that matches your write paths and backend topology. ## Encryption - Annotate sensitive fields with `@Encrypted` and configure providers/keys via `cfg.encryptionSettings()`. diff --git a/docs/developer-testing-guide.md b/docs/developer-testing-guide.md index 4d428c4b8..967d51a2a 100644 --- a/docs/developer-testing-guide.md +++ b/docs/developer-testing-guide.md @@ -178,6 +178,8 @@ Two tags have special semantics: `wire-failover` is different: it marks `DriverFailoverProxyTest`, which reproduces failover behaviour (clean stepdown, hard kill, frozen socket, and the resulting read/write/messaging recovery) through a reusable wire-level fault-injection proxy instead of controlling a real replica set process. It needs no hardcoded local setup and kills nothing, so it **does run in the normal matrix** — against both MongoDB and PoppyDB replica sets — and is not excluded by `runtests.sh` or any Maven profile. +The proxy behind that test (`WireProxy`, package `de.caluga.test.morphium.testutil.proxy`) is a general-purpose test utility, not failover-specific: runtime-switchable fault modes (freeze/reset/close), wire-level frame observation/logging, and response rewriting up to deliberately injecting invalid replies. See [Wire Proxy — Fault Injection & Wire-Level Monitoring](wire-proxy.md) for the full guide. + #### PoppyDB Options ```bash --poppydb # Start single-node PoppyDB (recommended) diff --git a/docs/howtos/cache-patterns.md b/docs/howtos/cache-patterns.md index 0ac82addb..f2ae7e9e0 100644 --- a/docs/howtos/cache-patterns.md +++ b/docs/howtos/cache-patterns.md @@ -34,7 +34,22 @@ messaging.start(); new MessagingCacheSynchronizer(messaging, morphium); ``` When to use -- Multi‑node deployments that need consistent caches after writes +- Multi‑node deployments that need consistent caches after writes made *through Morphium* +- Works on any backend/driver, including a standalone MongoDB or in‑memory driver — no replica set required +- Blind spot: writes done outside Morphium's storage listener (other apps, mongosh, restores) don't trigger invalidation +- Plus: it's a real message on a topic (`cacheSyncType`/`cacheSyncRecord`), not just a raw DB event — any other node or process that understands the message format can hook in and run extra, custom logic beyond clear/remove/update. Useful when cache‑clear logic is more involved than the built‑in strategies + +3b) Cluster‑wide synchronization via change streams +- Watches the underlying collections directly instead of relying on Morphium's own write path +```java +new WatchingCacheSynchronizer(morphium); // no messaging setup needed +``` +When to use +- The cached collections can be written by processes other than this Morphium cluster (other services, admin tools, migrations) — this catches those writes too, `MessagingCacheSynchronizer` would not +- You have a replica set available (Change Streams require one — same restriction as `messagingSettings().setUseChangeStream(true)`) and don't want to depend on messaging being healthy on every node +- Not for standalone/single‑node MongoDB — falls back to nothing, since there's no oplog to watch + +Pick one, not both, unless you specifically want redundant coverage. See [Developer Guide § Cache Synchronization](../developer-guide.md#cache-synchronization) for the full comparison. 4) TTL tuning and hot‑set sizing - Keep `@Cache.timeout` small enough to minimize staleness, large enough to reduce DB load diff --git a/docs/howtos/caching-examples.md b/docs/howtos/caching-examples.md index 6fcc157eb..8f077b1d1 100644 --- a/docs/howtos/caching-examples.md +++ b/docs/howtos/caching-examples.md @@ -23,19 +23,28 @@ public class Product { ... } morphium.getCache().setValidCacheTime(Product.class, 120_000); ``` -3) Cross‑node cache synchronization +3) Cross‑node cache synchronization — messaging‑driven ```java // Initialize messaging via factory and start it MorphiumMessaging messaging = morphium.createMessaging(); messaging.start(); -// Attach synchronizer: clears caches on other nodes when writes occur +// Attach synchronizer: clears caches on other nodes when writes occur *through Morphium* MessagingCacheSynchronizer sync = new MessagingCacheSynchronizer(messaging, morphium); // Optional: send a manual clear‑all sync.sendClearAllMessage("maintenance"); ``` +3b) Cross‑node cache synchronization — watching (change‑stream‑driven) +```java +// Watches the cached collections directly via a MongoDB Change Stream — no +// messaging setup needed, and it also catches writes made by other, non‑Morphium +// processes. Requires a replica set (Change Streams need an oplog). +WatchingCacheSynchronizer sync = new WatchingCacheSynchronizer(morphium); +``` +See [Cache Patterns](./cache-patterns.md) and the [Developer Guide](../developer-guide.md#cache-synchronization) for guidance on which one to pick. + 4) Switch to JCache implementation ```java // Use javax.cache‑based cache impl @@ -72,5 +81,5 @@ Notes - `CLEAR_TYPE_CACHE`: clear entire type cache on write - `REMOVE_ENTRY_FROM_TYPE_CACHE`: remove single entry by ID - `UPDATE_ENTRY`: re‑read updated entries -- Ensure messaging is running on all nodes if you want cluster cache synchronization. +- Two synchronizer implementations exist: `MessagingCacheSynchronizer` (needs messaging running on all nodes, catches only writes made through Morphium) and `WatchingCacheSynchronizer` (needs a replica set, catches any write to the watched collection). Pick one per deployment — see [Cache Patterns](./cache-patterns.md). See also: [Cache Patterns](./cache-patterns.md), [Developer Guide](../developer-guide.md) diff --git a/docs/howtos/messaging-implementations.md b/docs/howtos/messaging-implementations.md index bec3308fb..ea5044dd2 100644 --- a/docs/howtos/messaging-implementations.md +++ b/docs/howtos/messaging-implementations.md @@ -16,7 +16,7 @@ Morphium provides three messaging implementations that share the same API (`Morp - Lock collections per topic: `_lck_`. - Optimized change stream efficiency and reduced contention on busy/many‑topic systems. -- **Dual Channel (`DualChannelMessaging`, BETA, since 6.4.0)** +- **Dual Channel (`DualChannelMessaging`, BETA, since 6.3.0)** - A complete fork of Standard: identical single-collection layout and change-stream cursor for broadcast/topic traffic - bit-for-bit the same backpressure/window behavior as Standard. - Adds a *second* delivery lane purely for directed messages and answers: each participant gets @@ -88,6 +88,15 @@ dual-write bridge between the collection layouts. for DM/answer delivery to work in both directions. Every `DualChannelMessaging` instance logs a `WARN` on startup restating this. Migrate with the same big-bang or bridge approach described under "Migrating Standard → MultiCollection" below (the same caveats apply). +- Since **6.3.1**, mismatches are detected (#280): every instance — regardless of implementation — + announces itself in a layout-independent `_participants` collection (heartbeat document, + withdrawn on `terminate()`) and checks what the other participants run on startup. The channel + is deliberately not the messaging itself: between two implementations without a shared + collection, a messaging-based warning could never arrive. Behavior is configurable via + `MessagingSettings.ImplementationCheck`: `WARN` (default) logs the mismatch on startup and when + a mismatched participant joins later; `THROW` refuses startup of the mismatched instance with an + `IllegalStateException` (later joins still only warn — throwing on a background thread reaches + nobody); `IGNORE` disables announcement and check entirely. ## Measured Behavior Under Load (July 2026) @@ -118,6 +127,13 @@ Before that reorder the return leg carried an extra majority-acked write, making expensive as the outbound leg (measured 2.0× → 1.0× after the fix, ~40% lower request/reply RTT on MongoDB). +These floors are for the broadcast (non-exclusive) path. **Exclusive** request/reply — the +profile production services actually use — additionally pays the lock/claim machinery per +message, which is nearly free on PoppyDB (~+1 ms median) but costly on MongoDB (~+8 ms median, +with p99 tails growing into the seconds at only 100 msg/s). Measured numbers for both backends +and both paths: see [Performance Comparison](../v5-vs-v6-performance.md), section +"Exclusive request/reply". + ### Throughput ceiling and overload behavior — MongoDB Steady-state window (offered rate 175–225 msg/s, well past every implementation's knee): diff --git a/docs/howtos/migration-v6_2-to-v6_3.md b/docs/howtos/migration-v6_2-to-v6_3.md index e2a7b552e..0cf627b6c 100644 --- a/docs/howtos/migration-v6_2-to-v6_3.md +++ b/docs/howtos/migration-v6_2-to-v6_3.md @@ -4,8 +4,13 @@ This guide covers breaking changes, deprecations, and the headline new features from Morphium 6.2.x to 6.3.0. 6.3.0 is a large release, dominated by InMemoryDriver/PoppyDB correctness and production-readiness work; if you only use Morphium against real MongoDB and never touch the embedded driver or PoppyDB, most of this guide does not apply to you — skip to -[Breaking Changes That Affect Real MongoDB Users](#breaking-changes-that-affect-real-mongodb-users) -and [New: DualChannelMessaging](#new-dualchannelmessaging-beta). +[Breaking Changes That Affect Real MongoDB Users](#breaking-changes-that-affect-real-mongodb-users), +[New: DualChannelMessaging](#new-dualchannelmessaging-beta) and +[New: Messaging improvements](#new-messaging-improvements-all-implementations). + +If you use the standalone `io.quarkiverse.morphium:quarkus-morphium` artifact, read +[New: Optional Extension Modules](#new-optional-extension-modules-morphium-jakarta-data-quarkus-morphium) — +its Maven coordinates changed. No dependency version bumps in this release (Netty/BSON/SLF4J/Logback are unchanged from 6.2.10). @@ -13,6 +18,8 @@ No dependency version bumps in this release (Netty/BSON/SLF4J/Logback are unchan ### Mid-message read timeouts now close the connection instead of silently reusing it +*(Shipped in 6.2.10 — skip if you are coming from that patch release.)* + A socket timeout that struck mid-reply (header consumed, body still in flight) used to leave the connection desynchronized but still pooled — the next borrower would see cryptic `Illegal opcode` errors or a `null` reply. The driver now detects this case and closes the connection instead of @@ -35,6 +42,85 @@ permanently dead (`No primary node found`) even after the cluster recovered, req application restart. It now re-seeds from the configured host list and resumes discovery on its own. No action needed — this only removes a failure mode. +### The driver adopts the server's real wire limits, and oversized write batches are split + +`PooledDriver` ignored the `hello` handshake's `maxMessageSizeBytes`, `maxWriteBatchSize` and +`maxBsonObjectSize` and kept `DriverBase`'s field defaults instead (a 16MB message bound, batch +size 1000, and a `12*1025*1024` typo for the BSON limit) — only `SingleMongoConnectDriver` adopted +the advertised values. All drivers adopt them now (defaults are MongoDB's real 48MB/100000/16MB), +and a write command whose payload would exceed the message bound is cut into sub-batches +(`WriteBatchSplitter`) instead of going out as one huge `OP_MSG` that any real server answers by +closing the connection. **What to change:** nothing. A very large `insert`/`update`/`delete` batch +may now be executed as several wire messages; the results are folded back into one mongod-shaped +answer (counters summed, `writeErrors`/`upserted` indices remapped to your original statement +positions), and an *ordered* batch still stops at the first sub-batch that reports write errors. + +### Change-stream restarts resume where the dead stream stopped — and the messaging fallback poll really runs + +*(Shipped in 6.2.10 — skip if you are coming from that patch release.)* + +A change stream that died before its consumer had received any event had no resume token, so the +re-established stream started at "now" and everything written during the retry gap was silently +skipped — for messaging that meant lost messages. `watch()` now captures the cursor's +`postBatchResumeToken` (which MongoDB sends in every reply, including empty batches) and publishes +the freshest token on the `WatchCommand`, so `ChangeStreamMonitor` resumes from it. Messaging +additionally does one catch-up poll every time a watch is (re-)established. Related: the messaging +fallback poll was documented as running every second but was effectively gated to roughly every +125 seconds by a tick counter; it is time-based now and defaults to 10s. + +**What to change:** nothing, but expect a slightly higher steady-state query rate per messaging +instance than in 6.2.x, since the safety-net poll now actually fires at its configured interval. +Tune with `cfg.messagingSettings().setMessagingFallbackPollInterval(...)`. + +### Answers sent without an explicit TTL are no longer stored already expired + +`Msg.sendAnswer` computed `deleteAt = now + getTtl()` *before* any TTL defaulting ran, so an answer +built with a plain `new Msg()`/`new JMSMessage()` (ttl 0 — the JMS ack pattern) was written with +`deleteAt = now` and could be deleted by the TTL sweeper between its change-stream event and the +consumer's read (roughly 1–5% of runs — the long-hunted answer-timeout flakiness). `sendAnswer` +now leaves `deleteAt` unset when no TTL was chosen, so the send path applies `messagingDefaultTtl` +(30s) first. Explicit answer TTLs behave exactly as before. **What to change:** nothing; if you set +an explicit TTL on every answer to work around sporadic answer timeouts, you can drop that. + +### Client-side wire compression (snappy/zlib) works + +`SingleMongoConnection.sendQuery()` gave the `OP_COMPRESSED` envelope a *fresh* request id while +the reply matcher waited for the inner message's id, so every reply triggered `connection out of +sync`, killed the connection and eventually removed the host from the pool (`No such host`). +Client-side compression is usable now against both MongoDB and PoppyDB; server-side-only +compression was never affected. **What to change:** if you disabled client-side compression as a +workaround, you can turn it back on. + +### Smaller behavior changes and additions + +- **`getLastConnectFailure()` is cleared when a connect succeeds** — a caller polling it after a + recovery no longer sees the pre-recovery error as if it were current. +- **The read-preference fallback no longer throws a raw `NullPointerException`** past every retry + when the heartbeat nulls `primaryNode` exactly while the fallback runs. It works on a local + snapshot now. +- **The `hello` handshake reports the real Morphium version and driver name.** `driver.version` was + hardcoded to `"6.2"` and `driver.name` came out as `Morphium V6/unknown` on the connect + handshake; both are resolved at runtime now (`MorphiumVersion.getVersion()`, also working in + GraalVM native images), so `db.currentOp()`, server logs and the profiler show the actual patch + level. +- **New `DriverSettings.appName`** (default `"Morphium"`), sent as `client.application.name` in the + handshake — set it per service (`cfg.driverSettings().setAppName("order-service")`) to tell + instances apart in `db.currentOp()` and the server log. MongoDB truncates values over 128 bytes. + Third-party `MorphiumDriver` implementations keep compiling: the new interface methods are + `default`s. +- **Subclassed drivers work with generic command dispatch again.** Both `runCommand` and + `sendCommand` resolved their handler method via `getClass().getDeclaredMethod(...)`, which fails + for a subclass; the lookup is now anchored on the declaring driver class. Only relevant if you + extend `PooledDriver`/`SingleMongoConnectDriver`/`InMemoryDriver`. +- **`BufferedMorphiumWriterImpl` no longer NPEs** when the flusher removes a type's buffer while + another thread is between check and use (including the `WRITE_OLD`/`DEL_OLD` buffer-full + strategies). +- **`MultiCollectionMessaging` no longer marks skipped messages as "recently completed".** A + message the change-stream listener skipped *without* processing it (already processed elsewhere, + lock lost, reread failed) was recorded in `recentlyCompletedMessages` anyway, so a requeue within + the 10s retention window was invisible to both the listener and every poll. Only messages that + actually reached a listener are recorded now. + ## Breaking Changes in InMemoryDriver / PoppyDB These only affect you if you run tests against the InMemoryDriver (`-Dmorphium.driver=inmem`) or @@ -73,12 +159,187 @@ because they were relying on previously-wrong lenient behavior. or degraded performance. Tune with `--memory-warn`/`--memory-reject` (PoppyDB) or `setMemoryWatermarks(...)` (embedded); `100` disables the corresponding threshold. Updates, deletes, and TTL expiry are always allowed (the drain paths must keep working). +- **Date expression operators evaluate in UTC, and `$month` is 1-based** (#250). All date-component + operators used the JVM's default timezone, so results depended on the deployment environment. + Additionally `$month` was 0-based, `$isoWeek` returned the week-of-*month*, `$isoWeekYear` a week + number instead of a year, `$isoDayOfWeek` used Java's Sunday=1 numbering, and `$week` followed the + JVM locale's week rules. All of these now match MongoDB — **if you compensated for any of them + (the classic `+1` on `$month`), remove the workaround.** +- **Several `Expr` operators returned silently wrong values and now compute correctly**: `$asinh` + computed *sinh*, `$setUnion` collected the arrays instead of their elements, `$ln` computed + `ln(1+x)`, `$range` returned an empty list for descending ranges, `$reverseArray` mutated its + source list in place, the single-argument forms of `$avg`/`$max`/`$min` returned an array + argument unchanged instead of reducing it, and `$dateFromParts` returned its own + `{"$dateFromParts": {...}}` map instead of a `Date` (#246/#253/#255/#260). Two-argument `$atanh` + now raises an error instead of silently returning `0`. +- **`$group`'s `$avg` no longer leaks a `$_calc_` key** into every group output document + (#238) — group results lose a field that was never meant to be there. +- **Unimplemented stages and commands fail instead of quietly doing something else.** + `$planCacheStats`, `$redact`, `$unionWith`, `$currentOp`, `$listLocalSessions`, `$findAndModyfy` + and `$update` shared a `switch` body with `$bucket` and silently ran *its* logic (#237); + `$indexStats` silently ran `$geoNear` (#243). All of them now return "Unrecognized pipeline stage + name" (40324). Unknown *commands* are answered mongod-shaped with + `{ok: 0, code: 59, codeName: "CommandNotFound"}` instead of `InMemoryDriver.runCommand` throwing + `IllegalArgumentException` — **an embedded caller that caught that exception must inspect the + reply document instead.** `top` answers `CommandNotSupported` (115). +- **`dbStats`/`collStats` report real byte sizes instead of zeros**, and `dbStats` is scoped to the + requested database instead of returning global counts (#247). Assertions expecting `0` for + `dataSize`/`storageSize`/`avgObjSize`, or a global collection count from `dbStats`, will fail. +- **PoppyDB reports its real version.** `buildInfo.version`/`serverStatus.version` were hardcoded to + `5.0.0-ALPHA` and hello's `msg` said `PoppyDB V0.1ALPHA (Netty)`; all three now carry the actual + product version (`6.3.0`), so mongosh greets you with `Using MongoDB: 6.3.0`. Tooling that gates + on that string sees a different value — protocol capabilities are still negotiated via + `maxWireVersion`, which is unchanged. +- **PoppyDB's `rs.status()` speaks MongoDB, not Raft.** The self member's `stateStr` is + `PRIMARY`/`SECONDARY`/`RECOVERING` instead of the internal `LEADER`/`FOLLOWER`/`CANDIDATE`, and a + node started with `--bind 0.0.0.0` identifies itself by its seed entry instead of showing up + twice (once as `0.0.0.0:`, once wrongly marked SECONDARY). Monitoring that parsed the Raft + names must be updated. A peer that died with a failover is now reported `DOWN` after the + heartbeat grace period instead of staying `SECONDARY` forever. +- **PoppyDB enforces primary-only writes, `$readPreference`, transaction context and write concern + on the fast path.** The hot-dispatch handlers (insert/find/update/delete/count/distinct/ + createIndexes) bypassed all of it: a secondary silently accepted writes, and `w`/`wtimeout` were + ignored for those commands. Both are enforced now — a `w > 1` write actually waits for + replication (and can now report a `writeConcernError`), and a write sent to a secondary is + rejected with `NotWritablePrimary`. User management (`createUser`/`updateUser`/`dropUser`) is + primary-only for the same reason. +- **PoppyDB picks up a configuration file automatically.** In addition to `--cfg`/`-f` and + `$POPPYDB_CONF`, PoppyDB now reads the first existing of + `${XDG_CONFIG_HOME:-~/.config}/poppydb/config`, `~/.config/poppydb.conf`, `/etc/poppydb/config`, + `/etc/poppydb.conf` — so a file left over on a host changes what a server does without any CLI + change. Pass `--no-config` to skip the four default locations. An unknown key aborts startup with + a "did you mean" suggestion instead of being ignored. +- **PoppyDB validates its options at startup.** Ranges and cross-option consistency (e.g. `port` in + range, `memory-warn <= memory-reject`) were unchecked before; an invalid combination now aborts + startup, reporting all configuration errors at once. Use `--check-config` (exit code 0/1, like + `nginx -t`) to validate without starting a server. + +## Behavior Fixes You Should Know About in InMemoryDriver and PoppyDB + +These are bug fixes, not API changes — but each of them changes what the driver *does* with data +you already have, so they are worth reading before you upgrade a running system. + +### TTL indexes expire again after a structural change — expect old documents to disappear (#269) + +The TTL sweep is queue-driven, and `invalidateTtlQueue()` discards a collection's queue at every +structural change (drop, clear, rename, transaction commit/abort), relying on a lazy +rebuild-on-miss. Only one of the two paths that can find the queue missing actually rebuilt it: +`sweepTtlQueue()` bootstrapped from a full scan, while `ttlEnqueue()` installed a fresh queue +holding nothing but the one document it was called for. That queue was no longer "absent", so the +sweep's bootstrap never fired again and **every document that existed before the invalidation +permanently lost its expiry tracking**. + +This is exactly the mechanism Morphium's messaging relies on (`Msg.deleteAt` carries +`@Index(options = "expireAfterSeconds:0")`), and PoppyDB runs on this driver — so a `msg` +collection could grow without bound once the window had opened. Note which operations actually +open it: a transaction commit or abort, `dropIndexes`, and clearing, dropping or renaming a +collection. Creating an index does *not* — `createIndex` bootstraps the queue directly instead of +invalidating it — so simply starting a messaging node against an existing PoppyDB was never +enough on its own. + +**What to change:** nothing in your code — but if you have a long-running PoppyDB or embedded +InMemoryDriver instance whose collections grew and never shrank, the first sweep after the upgrade +will expire everything that is past its `expireAfterSeconds` bound. That can be a large, sudden +delete. Check the affected collections before restarting if you are unsure whether those documents +should still be there. Related: `dropIndexes` no longer leaves the TTL sweep registered for a +dropped TTL index (the driver kept deleting documents by an index that no longer existed), and a +renamed collection carries its capped/TTL bookkeeping to the new name (#239). + +### Transactions no longer diverge from — or silently lose writes against — the index store + +Three related defects in how `CollectionIndexStore` interacts with transactions: + +- A store **built during** an open transaction was populated from the transaction's private + snapshot, i.e. from cloned document instances. `abortTransaction()` did not invalidate it (only + `commitTransaction()` did), so it kept referencing orphaned clones forever and every later insert + under the same unique-index key was rejected as a duplicate — **even on a collection that had + been cleared to zero documents**. Both commit and abort now invalidate the store (and the TTL + queue) for every collection whose store was built while the transaction was open, not only for + the ones it wrote to. +- A store **built before** a transaction started holds live document instances, while the + transaction mutates its private clones. An index-backed equality lookup inside the transaction + therefore returned the pre-transaction instance (diverging from a full scan of the same + collection), and an update whose candidate came from that lookup mutated the live object instead + of the snapshot clone that commit merges back — **the write was silently lost on commit although + it succeeded without error inside the transaction.** `getIndexStore()` now records which + transaction context a store was built from and only reuses it for that caller. +- The provenance check originally evicted a mismatching entry, which made a transaction rebuild its + index store on *every* operation for its whole lifetime (measured: 20 rebuild passes for 20 + operations, 1 with the fix). Ownership now changes via an atomic compare-and-swap instead. + +**What to change:** nothing. If you saw spurious `duplicate key` errors or lost updates when using +transactions against the InMemoryDriver/PoppyDB, they are gone. + +### PoppyDB replica sets no longer lose data during a stepdown + +A re-syncing secondary ran its initial-sync wipe (`clearLocalDatabases()`) and snapshot copy as +regular commands, so they emitted live change-stream events — including `drop admin.system.users`. +During a stepdown the demoted ex-primary starts re-sync attempts immediately while the other nodes' +old `ReplicationManager`s are still watching it, and they faithfully applied those wipe-drops to +their own data; even a freshly promoted primary could apply the demoted node's wipe at promotion +time. Whether a user created on the new primary survived on any given node was pure timing. +Initial-sync writes now run inside `InMemoryDriver.suppressChangeStreamEvents()`, mirroring +MongoDB, where initial-sync writes are never oplogged. Steady-state replication still emits events. + +Two more failover fixes in the same area: a demoted leader could keep `primary == true` forever +after a rapid leadership flap (and a node stuck like that silently never replicates), and a demoted +but still-running leader now resumes replication toward the new primary immediately instead of +waiting for an unrelated later leader change. + +### Smaller correctness fixes that change results + +Re-run your suite against InMemoryDriver/PoppyDB after upgrading — these all used to succeed while +doing the wrong thing: + +- **Query operators** (#251): `$size` matched documents whose field is entirely absent, `$all` with + an empty array matched everything (MongoDB matches nothing), `$all` + `$elemMatch` never matched, + `$mod` threw a `ClassCastException` on array-valued fields, `$type` ignored the array-of-types + form, and the bits operators decoded `byte[]` masks backwards. `$geoWithin` with + `$center`/`$centerSphere`/`$polygon` **matched every document in the collection** (#242). +- **Update operators** (#249): `$pull` with `$elemMatch` never removed anything, `$rename` with a + dotted source destructively removed the *target* field, `$min`/`$max` threw an NPE on an absent + field, `$mul` was a no-op on a missing field, `$currentDate` only wrote the first listed field, + and `$push`'s `$sort` modifier did nothing. `$unset` through array-index path segments + (`ratings.0.rating`) was a silent no-op and works now. +- **`store()` on an existing document** failed with `E11000 duplicate key` — the ordinary "find it, + change it, store it back" round-trip threw for every existing document and left the index in an + inconsistent state. +- **`$sample` with a size larger than the collection** threw `IndexOutOfBoundsException` instead of + returning all documents (visible in every mongosh tab completion against PoppyDB). +- **`renameCollection` dropped all index definitions** on the renamed collection (#248), and + `listIndexes` swallowed `partialFilterExpression` — which would have replicated partial indexes + as full ones. +- **Resumed change streams could deliver an event twice** (and out of order) when it was written + exactly between subscription registration and history replay. Resumed subscriptions now suppress + duplicates by resume token. Fresh watches were never affected. Mostly relevant for custom + `ChangeStreamListener`s — messaging and PoppyDB replication were already idempotent. +- **PoppyDB's wire fast path dropped client options** (#244/#252/#256): `createIndexes` forwarded + only `unique`/`name` and silently dropped `expireAfterSeconds` (**a TTL index created over the + wire never expired anything**), `sparse`, `background`, `hidden` and `partialFilterExpression`; + `insert` hardcoded `ordered=true`; `update`/`delete`/`count`/`distinct` hardcoded `collation` to + null; and `update` dropped `arrayFilters`, so `$[]` updates failed over the wire while + working embedded. +- **The change-stream event dispatcher no longer uses virtual threads** (#234) — under JDK 21 it + could pin every carrier thread of the common ForkJoinPool while parked on the logback appender + lock, freezing every thread that logs (observed as a 20+ minute hang). +- **A duplicate `_id` can no longer slip past the insert pre-check** because caller and store hold + the same id in different wrapper types (`MorphiumId` vs `ObjectId`) — the check now runs through + the `_id` index and its normalization. Ordered inserts still throw, unordered ones still collect a + code-11000 `writeError`. +- **PoppyDB's wire insert fast path no longer labels every driver exception as a duplicate-key + error (11000)** — typed codes (e.g. `ExceededMemoryLimit` 146) pass through to the client now, so + error handling that branched on 11000 sees the real code. +- **PoppyDB's `hello` no longer pays a ~30s reverse-DNS lookup** on hosts without working rDNS when + the replica-set seed list already names the member — a startup/handshake stall, not a data issue, + but a very visible one. ## Deprecations — the 7.0-removal wave (#218) Members confirmed for removal in 7.0 now carry `@Deprecated(since = "6.3", forRemoval = true)`, so IDEs flag every usage a full minor release ahead of time. This is a pure annotation/Javadoc -change — nothing behaves differently in 6.3.0, and everything listed still works. Covered: +change — nothing behaves differently in 6.3.0, and everything listed still works. (The annotations +themselves already shipped in 6.2.9; if you upgrade from 6.2.9/6.2.10 your IDE has been flagging +them for a while.) Covered: - Flat `MorphiumConfig` setters/getters — use the `Settings` sub-objects instead (`connectionSettings()`, `objectMappingSettings()`, `messagingSettings()`, ...). @@ -120,6 +381,49 @@ delivery is push-based and was never cursor-cadence-bound the way mongod's oplog measured numbers in [Messaging Implementations](./messaging-implementations.md). Marked `@Beta`: behavior, collection layout, or API surface may change without a deprecation cycle. +## New: Optional Extension Modules (`morphium-jakarta-data`, `quarkus-morphium`) + +Morphium is being split into a core plus opt-in extension modules. Two of them ship with 6.3.0. +The dependency direction is strictly one-way — core has no knowledge of either module, so an +application declaring only `de.caluga:morphium` gets exactly what it got in 6.2.x; nothing new +lands on your classpath unless you add the module yourself. + +- **`morphium-jakarta-data`** — a [Jakarta Data 1.0](https://jakarta.ee/specifications/data/1.0/) + provider on top of Morphium's query engine: `@Repository` interfaces with query derivation from + method names (`findByCategory`, `countByStatus`, `deleteByX`, `And`/`Or`/`Between`/`In`/`Like`/ + `OrderBy`), JDQL via `@Query` (including `GROUP BY`/`HAVING` compiled into an aggregation + pipeline), `@Find`/`@Delete` with `@By` binding, offset (`Page`) and cursor/keyset + (`CursoredPage`) pagination, static and dynamic sorting. Framework-agnostic by design — it is + meant to be consumed by framework integrations. See [Jakarta Data](../jakarta-data.md). +- **`quarkus-morphium`** — a Quarkus CDI extension: a producer for `Morphium`, typed config + (`quarkus.morphium.*`), `@MorphiumTransactional` with CDI transaction events, SmallRye + liveness/readiness/startup health checks, Dev Services, a Dev UI card, build-time Gizmo-generated + Jakarta Data repository implementations (no runtime reflection or proxies), GraalVM native-image + reflection registration for `@Entity`/`@Embedded`, `MorphiumId` JSON serialization as its + canonical 24-char hex string, and a MongoDB-backed migration runner with a distributed lock. See + [Quarkus Extension](../quarkus-extension.md). + +### Breaking: `quarkus-morphium` moved to the `de.caluga` groupId + +The Quarkus extension previously published as `io.quarkiverse.morphium:quarkus-morphium:1.2.0`. It +does not actually live in the Quarkiverse GitHub organization, so its Maven coordinates now follow +Morphium's own groupId and version in lockstep with the reactor. **What to change:** update the +dependency's `groupId` to `de.caluga` and its version to the Morphium version you adopt: + +```xml + + de.caluga + quarkus-morphium + 6.3.0 + +``` + +No package renames, no API changes — only the coordinates move. The module publishes +`quarkus-morphium` (runtime), `quarkus-morphium-deployment` and `quarkus-morphium-testing`. + +Building the reactor with `-DskipExtensions` produces a core-only build (core + PoppyDB), exactly +as before this change. + ## New: PoppyDB — production-readiness features - **DevOps command surface**: `db.currentOp()`/`killOp` (with a real op registry), `rs.conf()`, @@ -128,14 +432,56 @@ behavior, collection layout, or API surface may change without a deprecation cyc - **Opt-in auth enforcement** (`--auth`) with real server-side SCRAM-SHA-1/SHA-256 verification (RFC 5802/7677) and a working `createUser`. Without `--auth`, behavior is unchanged (fully open). Authorization is authentication-only for now — roles are stored but not evaluated. +- **A complete user lifecycle**: `createUser`, the newly added `updateUser` (in-place password/role + rotation) and `dropUser`, all with optional `customData`, all replicated. `updateUser` no longer + resets a user's SCRAM mechanism set on a password change (a SHA-256-only user was silently + re-armed for SHA-1), and a password change no longer discards stored `customData`; malformed + field types produce a `BadValue` command error instead of an uncaught `ClassCastException`. +- **`admin.system.users` replicates across the replica set** — users were node-local before, so a + secondary never had the same logins as the primary and a failover (or a dump taken on a + priority-0 node) silently lost them. It is now the one system collection that replicates, through + live events, the initial-sync snapshot and resync-clear alike. +- **Declarative user provisioning** via `--users-file ` — a JSON file (bare array, or + `{"version": N, "users": [...]}`) applied as an idempotent `createUser`/`updateUser` upsert + wherever `ensureRootUser` runs. The optional `version` gates re-application against a replicated + meta document, so a straggler node cannot roll credentials back on failback. Duplicate + `(user, db)` entries and unknown fields are hard errors (previously silent last-entry-wins); + file permissions are checked and the content is never logged. +- **Configuration file support** (`--cfg`/`-f`, `$POPPYDB_CONF`, plus four default locations, + `--no-config` to skip them) with uniform precedence CLI > file > default, `--no-ssl`/`--no-auth` + to switch a file's booleans back off, and `root-password-file`/`ssl-keystore-password-file` so + secrets stay off the command line (where `ps aux` exposes them for the life of the process). + Files carrying secrets are permission-checked: group/other-readable warns, group/other-writable + refuses to start. See the note in Breaking Changes above about automatic discovery. +- **`--print-config`/`--check-config`** — print the effective configuration (secrets redacted, with + per-key source annotations) as a reusable config file, or validate syntax, semantics and deep + checks (keystore loadable, dump dir usable) without starting the server. - **TLS actually works now** — it was silently broken (NPE on startup) whenever an `SSLContext` was - configured. + configured. **On a replica set, `--auth` and `--ssl` each made the cluster completely + non-functional**: the internal election and replication channels connected to peers as plain, + unauthenticated, unencrypted clients, so with `--ssl` every internal connection was rejected by + the peer's TLS listener and with `--auth` every election RPC was rejected as unauthorized — no + leader could ever be elected. The internal channel now authenticates as the configured root user + and pins the server's own certificate as its truststore. No new config keys. - **`--log-level` option** — the CLI jar no longer floods disks by logging everything at DEBUG by default (root now defaults to `INFO`). - **Replication correctness**: index definitions are now replicated (previously documents only — unique constraints, TTL, and index-backed queries silently didn't work on secondaries or after failover); replication is now lossless and order-preserving; a long-standing election bug that - kept followers from ever starting replication is fixed. + kept followers from ever starting replication is fixed. A leader change with byte-for-byte + identical data (verified per namespace via `dbHash`) now skips the clear-and-full-resnapshot. +- **Consistency checks with teeth**: `dbHash` (MD5 per collection over the BSON-encoded documents in + a canonical order, answered on secondaries too — the one-command check that two members hold the + same data) and a real `validate` that walks the index store and reports index entries pointing at + removed documents and documents missing from an index. +- **Resource leaks closed**: find cursors are cleaned up when a client disconnects, idle cursors + expire via TTL, and watch/tailable event queues are bounded (they were unbounded before). +- **Messaging throughput**: the dead `msg_locked_by_1_locked_1` index that `MessagingOptimizer` + created on every registered messaging collection is gone — the fields it indexed no longer exist + on `Msg` (locking moved to the separate `MsgLock` collection long ago) and nothing ever queried + it, so all it did was add per-insert maintenance cost on the hottest collection. The insert + duplicate-`_id` pre-check is an O(1) index lookup instead of a full collection scan under the + write lock, which was the dominant per-insert cost on large collections. - **Memory watermarks** and **BSON/message size enforcement** — see Breaking Changes above. ## New: InMemoryDriver aggregation & query surface @@ -150,13 +496,19 @@ or PoppyDB instead of a real server: operators, `$sortArray`, `$round`, `$median`/`$percentile`, and more). - Positional update operators `$`, `$[]`, `$[]` with `arrayFilters` (also reachable from the high-level API via the new `Query.setArrayFilters(...)`), and `$bit`. -- `dbHash`, `validate`, `currentOp`, real `serverStatus`, and the MongoDB-8.0-style top-level - `bulkWrite` command. +- `dbHash`, `validate`, `currentOp`, real `serverStatus`, `$collStats`/`$listSessions`, and the + MongoDB-8.0-style top-level `bulkWrite` command. +- Typed `Aggregator` builder methods for the new stages — `documents(...)`, `densify(...)`, + `fill(...)`, `setWindowFields(partitionBy, sortBy, output)` — instead of `genericStage()`. + Implemented in both `AggregatorImpl` and `InMemAggregator`, with the same field-name translation + as every other typed stage method. If any of your tests were relying on a previously-stubbed or silently-wrong behavior in this area -(several dozen correctness fixes shipped alongside the new features — see `CHANGELOG.md`'s -`[Unreleased]`/6.3.0 section for the full list), re-run your suite against InMemoryDriver/PoppyDB -after upgrading. +(several dozen correctness fixes shipped alongside the new features — see the 6.3.0 section of +`CHANGELOG.md` for the full list, and +[Behavior Fixes You Should Know About](#behavior-fixes-you-should-know-about-in-inmemorydriver-and-poppydb) +above for the ones most likely to change your results), re-run your suite against +InMemoryDriver/PoppyDB after upgrading. ## New: Messaging improvements (all implementations) @@ -167,20 +519,69 @@ after upgrading. - Change-stream liveness now drives the fallback poll directly — a silent stream triggers an immediate poll instead of waiting for the next timer tick. - A bounded processing-decision trace aids answer-timeout diagnostics (dumped only on timeout, not - during normal operation). + during normal operation). Also exposed as `getProcessingDecisions(msgId)`. + +### Non-exclusive messages are deserialized from the change-stream snapshot + +`SingleCollectionMessaging` re-read every incoming message by `_id` (PRIMARY read preference) +before processing it, although the insert event already carried the complete document. For the +safe case — a **non-exclusive** message arriving via an insert event with a `fullDocument` — the +message is now deserialized directly from the event snapshot, saving one DB roundtrip per message. +Everything with staleness risk deliberately keeps the re-fetch: exclusive messages (the +`processed_by` re-check after claiming the lock is correctness, not overhead), requeue updates, +poll pickups, and any snapshot that fails to deserialize. All skip checks (listener existence, +sender == self, processed-by, recipients, answer matching) run unchanged. + +**What this means for you:** + +- **Entity lifecycle callbacks fire on this path too.** The first version deserialized via the raw + `ObjectMapper`, which — unlike the query path — fires no lifecycle callbacks, so `@PostLoad` was + skipped for non-exclusive messages. That also silently broke V5-legacy messages: `Msg.postLoad()` + is where the V5→V6 compatibility migration lives (`topic = name` when only the legacy `name` + field is set), so a message written in V5 format without a `topic` (e.g. via `storeMap()`) + arrived with `topic == null` and was dropped by the "no listener for this topic" check — no + exception, no fallback, on every backend. The fast path now fires `firePostLoadEvent()` right + after a successful deserialize, matching the query path, and falls back to the re-fetch path if + the callback throws. Both the optimization and this fix ship in 6.3.0, so upgrading from 6.2.x + you never see the broken intermediate state — but **if your message entities carry `@PostLoad` + methods (or you still hold V5-format messages), verify delivery after the upgrade**: this is the + one path where a message is no longer built by the query path. +- The decision trace records which of the two paths a message took. ## Migration Checklist 1. [ ] **Search for `forRemoval = true` candidates** (see Deprecations above) and migrate opportunistically — not urgent for 6.3.0, but IDEs will now flag them. 2. [ ] **If you test against InMemoryDriver/PoppyDB**, re-run your suite — several dozen - correctness fixes may surface previously-masked test bugs (see the two Breaking Changes - sections above). -3. [ ] **If you run PoppyDB in production**, review the new `--auth`, `--memory-warn`/ - `--memory-reject`, and `--log-level` options — defaults preserve prior (open, unbounded, DEBUG) - behavior, so nothing changes unless you opt in. -4. [ ] **If you store documents that could exceed 16MB** or write batches that could exceed 48MB + correctness fixes may surface previously-masked test bugs (see the two Breaking Changes sections + and [Behavior Fixes](#behavior-fixes-you-should-know-about-in-inmemorydriver-and-poppydb) above). +3. [ ] **Check aggregation pipelines for date-operator workarounds.** `$month` is 1-based now, date + operators evaluate in UTC, and `$ln`/`$setUnion`/`$asinh`/`$reverseArray`/`$dateFromParts` and + the single-arg `$avg`/`$max`/`$min` return different (correct) values against + InMemoryDriver/PoppyDB. Anything that compensated for the old behavior is now wrong. +4. [ ] **If you run PoppyDB in production**, review the new `--auth`, `--users-file`, `--cfg`, + `--memory-warn`/`--memory-reject`, and `--log-level` options — defaults preserve prior (open, + unbounded, DEBUG) behavior, so nothing changes unless you opt in. **But** check the four default + config-file locations for leftover files (or pass `--no-config`), and validate your startup + options with `--check-config` before rolling out — options that were silently accepted before can + now abort startup. +5. [ ] **If you run PoppyDB or the embedded InMemoryDriver long-running**, be aware that TTL expiry + works again (#269): collections that stopped expiring documents will shed everything past their + `expireAfterSeconds` bound on the first sweep after the upgrade. Check before restarting if you + are unsure whether those documents should still be there. +6. [ ] **If you store documents that could exceed 16MB** or write batches that could exceed 48MB against InMemoryDriver/PoppyDB, verify you're within the now-enforced limits (or raise them). -5. [ ] **Optional:** if request/reply throughput is your bottleneck on real MongoDB and you can run - a homogeneous cluster, evaluate the beta `DualChannelMessaging` implementation. -6. [ ] No dependency version changes — nothing to reconcile in your own `pom.xml`. +7. [ ] **If you parse PoppyDB's `rs.status()`/`buildInfo` output** in monitoring, update it: + `stateStr` uses MongoDB's nomenclature now and the reported version is the real one (`6.3.0`), + not `5.0.0-ALPHA`. +8. [ ] **If your message entities have `@PostLoad` methods or you still hold V5-format messages**, + verify message delivery after the upgrade — non-exclusive messages now come from the + change-stream snapshot (lifecycle callbacks included; see Messaging improvements above). +9. [ ] **If you use `io.quarkiverse.morphium:quarkus-morphium`**, change the `groupId` to + `de.caluga` and the version to `6.3.x`. +10. [ ] **Optional:** if request/reply throughput is your bottleneck on real MongoDB and you can + run a homogeneous cluster, evaluate the beta `DualChannelMessaging` implementation. +11. [ ] **Optional:** set `cfg.driverSettings().setAppName(...)` per service so `db.currentOp()` + and the server log can tell your instances apart. +12. [ ] No dependency version changes — nothing to reconcile in your own `pom.xml` (adding + `morphium-jakarta-data` or `quarkus-morphium` is opt-in; core pulls in nothing new). diff --git a/docs/howtos/poppydb-deployment.md b/docs/howtos/poppydb-deployment.md index e91b02b9c..20c53e173 100644 --- a/docs/howtos/poppydb-deployment.md +++ b/docs/howtos/poppydb-deployment.md @@ -247,7 +247,7 @@ accordingly: - Rolling upgrade for a replica set: upgrade secondaries first (they resync from the current primary on restart), then step down the primary (`replSetStepDown` or restart it last) so a secondary takes over — verify *some* node became primary afterward rather than waiting for a - specific one (see [PoppyDB § StepDown/Failover Behavior](../poppydb.md#stepdown--failover-behavior-replica-set) + specific one (see [PoppyDB § StepDown/Failover Behavior](../poppydb.md#stepdown-failover-behavior-replica-set) for why the original primary may not reclaim leadership). - Take a manual snapshot immediately before upgrading (see §7) regardless of your regular dump interval. @@ -286,7 +286,7 @@ a replacement for reading the sections above. where the [loss model](../poppydb.md#5-message-broker-for-short-lived-messages-production) (loss between snapshots is acceptable) actually fits your data. - Don't wait for "the original primary" to reclaim leadership after a failover — verify *any* node - became primary instead (see [§8](#8-upgrades) and [PoppyDB § StepDown/Failover Behavior](../poppydb.md#stepdown--failover-behavior-replica-set)). + became primary instead (see [§8](#8-upgrades) and [PoppyDB § StepDown/Failover Behavior](../poppydb.md#stepdown-failover-behavior-replica-set)). - Don't skip the config-file **permission warning** — `chmod 600` any file PoppyDB tells you is group/other-readable and contains secrets, before it becomes group/other-*writable* and PoppyDB refuses to start entirely. diff --git a/docs/index.md b/docs/index.md index dcefb9a7f..bc3b56eb5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,9 +1,36 @@ # Morphium v6 Documentation +

+ Morphium + Morphium +

+ Morphium is a Java 21+ Object Document Mapper (ODM) and MongoDB‑backed messaging system. It includes a custom MongoDB wire‑protocol driver, distributed caching, and a topic‑based message queue. --- +

+ PoppyDBPoppyDB +

+ +## PoppyDB — MongoDB‑compatible Server + +PoppyDB is the project's second product: a standalone, self‑contained server that speaks the +MongoDB wire protocol — any MongoDB client (Java, Python, Node.js, Go, `mongosh`, ...) can +connect to it. Perfect for CI/CD pipelines, integration testing, and lightweight deployments. + +- **Replica Sets** with Raft‑based failover +- Opt‑in **Authentication (SCRAM)** and **TLS** +- **Persistence** via snapshots + +```bash +java -jar poppydb--cli.jar --port 27017 +``` + +→ **[PoppyDB Documentation](./poppydb.md)** · [Production Deployment Playbook](./howtos/poppydb-deployment.md) · [Migrating from MongoDB](./howtos/migration-mongodb-to-poppydb.md) + +--- + ## 🚀 New Here? Start Here! **Learning path for beginners:** @@ -26,11 +53,7 @@ Morphium includes a complete in-memory MongoDB-compatible implementation for tes - **[Developer Testing Guide](./developer-testing-guide.md)** - How to run and write tests, MultiDriverTestBase, runtests.sh - **[Test Runner](./test-runner.md)** - Quick reference for the `runtests.sh` script - **[InMemory Driver](./howtos/inmemory-driver.md)** - Embedded in-memory driver for unit tests (no MongoDB installation required!) -- **[PoppyDB](./poppydb.md)** - Standalone MongoDB-compatible server that speaks the wire protocol (formerly MorphiumServer) - - Perfect for CI/CD pipelines, integration testing, and microservices development - - Any MongoDB client (Java, Python, Node.js, Go, etc.) can connect to it - - Supports **Replica Sets** with Raft failover, **opt-in Authentication (SCRAM) & TLS**, and **Persistence (Snapshots)** - - Run with: `java -jar poppydb/target/poppydb--cli.jar --port 27017` +- **[PoppyDB](./poppydb.md)** - Standalone MongoDB-compatible server that speaks the wire protocol — see the [PoppyDB section](#poppydb-mongodbcompatible-server) above ## Production Deployment - **[Production Deployment Guide](./production-deployment-guide.md)** - Complete guide for deploying Morphium in production environments @@ -57,6 +80,19 @@ any of the following. These are additional, opt-in modules built on top of the c - Offset and cursor pagination (`Page`, `CursoredPage`), dynamic and static sorting - Zero dependency from the core: build with `-DskipExtensions` for a core-only artifact; framework integrations for Quarkus and Spring Boot build on top of this module +- **[Quarkus Extension](./quarkus-extension.md)** - Optional module integrating Morphium into + Quarkus applications via a CDI producer, `@ConfigMapping`, `@MorphiumTransactional`, health + checks, Dev Services, Dev UI, and build-time Jakarta Data repository generation via Gizmo + - GraalVM native-image support and `MorphiumId` JSON (de)serialization out of the box + - Zero dependency from the core: build with `-DskipExtensions` for a core-only artifact +- **[Spring Boot](./spring-boot.md)** - Optional module integrating Morphium into + [Spring Boot](https://spring.io/projects/spring-boot) applications via auto-configuration, + type-safe `@ConfigurationProperties` (`morphium.*`), `@MorphiumTransactional` via AspectJ, an + Actuator health indicator, and Jakarta Data `@Repository` interfaces backed by JDK dynamic + proxies at runtime (no build-time bytecode generation, unlike `quarkus-morphium`'s Gizmo + approach) + - No Docker/Testcontainers needed — all tests run against Morphium's `InMemDriver` + - Zero dependency from the core: build with `-DskipExtensions` for a core-only artifact Minimum requirements - Java 21+ @@ -86,7 +122,7 @@ Benefits - Tailored to Morphium’s mapping and lifecycle needs; minimal impedance with Morphium’s object mapper. - Full control over retry/failover semantics and performance trade‑offs. - SSL/TLS support for secure connections (since v6.0). +- MongoDB Atlas support via `mongodb+srv://` connection strings (DNS SRV/TXT resolution, TLS enabled automatically); see the [SSL/TLS guide](./ssl-tls.md#mongodb-atlas-example). Limitations -- No MongoDB Atlas support. - Some advanced features of the official driver are not available. diff --git a/docs/jakarta-data.md b/docs/jakarta-data.md index de247f953..9e1cd3eca 100644 --- a/docs/jakarta-data.md +++ b/docs/jakarta-data.md @@ -61,9 +61,9 @@ Jakarta Data and no compile- or runtime dependency on this module. ``` -In the Morphium reactor, `${project.version}` currently resolves to `6.3.0-SNAPSHOT`. -This module follows Morphium's regular release versioning; there is no separate version -line to track. +In the Morphium reactor, `${project.version}` resolves to whatever version the reactor is +currently on (see the root `pom.xml`). This module follows Morphium's regular release +versioning; there is no separate version line to track. ## Repository Interfaces diff --git a/docs/messaging.md b/docs/messaging.md index f391d2018..338e97a3b 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -364,4 +364,4 @@ Notes and best practices - Non‑exclusive messages are broadcast to all listeners of a topic - For delayed/scheduled handling, add your own not‑before timestamp field and have the listener re‑queue or skip until due; `Msg.timestamp` is used for ordering, not scheduling - For retries and DLQ, implement logic in listeners (inspect payload, track retry count, re‑queue or redirect to a DLQ topic) -- For distributed cache synchronization, see [Caching Examples](./howtos/caching-examples.md) and [Cache Patterns](./howtos/cache-patterns.md); Morphium provides `MessagingCacheSynchronizer`. +- For distributed cache synchronization, see [Caching Examples](./howtos/caching-examples.md) and [Cache Patterns](./howtos/cache-patterns.md); Morphium provides `MessagingCacheSynchronizer` (uses this messaging system) and `WatchingCacheSynchronizer` (uses Change Streams directly, no messaging needed) — see the [Developer Guide](./developer-guide.md#cache-synchronization) for which one to pick. diff --git a/docs/overview.md b/docs/overview.md index 3f5e4c803..c82f66204 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -72,5 +72,5 @@ Next steps Driver notes - Morphium uses its own wire‑protocol driver tailored to Morphium’s mapping. -- Limitations: No MongoDB Atlas support. +- MongoDB Atlas is supported via `mongodb+srv://` (DNS SRV/TXT resolution, TLS enabled automatically); see the [SSL/TLS guide](./ssl-tls.md#mongodb-atlas-example). diff --git a/docs/poppydb.md b/docs/poppydb.md index 1f4e7d03a..96ef7051b 100644 --- a/docs/poppydb.md +++ b/docs/poppydb.md @@ -1,5 +1,9 @@ # PoppyDB: Standalone MongoDB-Compatible Server +

+ PoppyDBPoppyDB +

+ PoppyDB is a standalone MongoDB wire protocol-compatible server built on the InMemoryDriver. Introduced in its mature form with **Morphium 6.1**, it allows any MongoDB client (Java, Python, Node.js, Go, etc.) to connect and interact with an in-memory database as a true **drop-in replacement** for MongoDB during development and testing. **Important:** PoppyDB can be run as a standalone application from a dedicated executable JAR, or used programmatically as part of a Java application. @@ -201,7 +205,7 @@ with code 0 (OK) or 1 (errors) - like `nginx -t`. Beyond syntax and semantic cro loaded (catching wrong keystore passwords), secret files are read, the dump directory is checked for usability, and — if `users-file` is set — the file is read, permission-checked and fully parsed/validated exactly like at real startup (see -[Bootstrapping users](#bootstrapping-users---users-file)), so a broken users file is caught before +[Bootstrapping users](#bootstrapping-users-users-file)), so a broken users file is caught before it can abort a real deployment. Warnings (e.g. `ssl` without a keystore) do not affect the exit code: java -jar poppydb.jar --cfg /etc/poppydb/config --check-config @@ -234,7 +238,7 @@ case/separator-insensitive) — flags without one are CLI-only (there is nothing | `--no-auth` | | Force auth off, overriding a config file's `auth=true`. | | | `--rootUser ` | `root-user` | Initial admin user, created at startup if absent. Required for a fresh `--auth` server — there is no localhost exception. | | | `--rootPassword ` | `root-password` | Password for the initial admin user. `root-password-file` (config-file only) reads it from a separate file instead. | | -| `--users-file ` | `users-file` | JSON file declaring users to provision at startup (idempotent upsert, primary-only apply, optional version gate). See [Bootstrapping users](#bootstrapping-users---users-file). | | +| `--users-file ` | `users-file` | JSON file declaring users to provision at startup (idempotent upsert, primary-only apply, optional version gate). See [Bootstrapping users](#bootstrapping-users-users-file). | | | `-d`, `--dump-dir ` | `dump-dir` | Directory for periodic database dumps. Enables persistence. | | | `--dump-interval ` | `dump-interval` | Interval between periodic dumps. 0 = only dump on shutdown. | `0` | | `--max-connections ` | `max-connections` | Maximum concurrent connections. | `500` | @@ -585,9 +589,9 @@ server.start(); crossing untrusted networks to also encrypt the data itself. **User replication:** in a replica set, `admin.system.users` is the one system collection that -replicates — users created or updated via `createUser`/`updateUser` reach every member, and -(like all writes) only the primary accepts these commands; a secondary answers them with -`NotWritablePrimary`. This means logins survive failover: a user created before a leadership +replicates — users created, updated or removed via `createUser`/`updateUser`/`dropUser` reach +every member, and (like all writes) only the primary accepts these commands; a secondary answers +them with `NotWritablePrimary`. This means logins survive failover: a user created before a leadership change can still authenticate against the new primary and against every secondary, and a dump taken on any member — including a priority-0 backup node that never leads — contains the users, not just the data. Before this change users were node-local, so a backup-node dump silently @@ -598,7 +602,7 @@ brief window before the new primary has (re-)created the root user, during which transiently fail until that completes. For provisioning more than the one initial admin user declaratively, see -[Bootstrapping users (`--users-file`)](#bootstrapping-users---users-file) below — a JSON file of +[Bootstrapping users (`--users-file`)](#bootstrapping-users-users-file) below — a JSON file of users applied the same idempotent, primary-only, replication-riding way `--rootUser` is. **SSL with Docker:** @@ -638,7 +642,7 @@ becomes primary — no manual `createUser` shell commands, no drift between envi Per entry: `user` and `pwd` are required non-empty strings; `db` defaults to `"admin"`; `roles` is optional and stored mongod-shaped but **not enforced** (like everywhere else in PoppyDB — -see [Current limitations](#authentication---auth) above); `mechanisms` is optional. Any unknown +see [Current limitations](#authentication-auth) above); `mechanisms` is optional. Any unknown field in an entry, or at the top level, is a hard error naming the field (and the entry index) instead of being silently ignored. Two entries naming the same `(user, db)` pair are a hard error too — mongod identifies a user by that pair, so both would apply to the same principal; without @@ -657,15 +661,19 @@ java -jar poppydb-cli.jar --auth --rootUser admin --rootPassword s3cr3t \ instead of silently leaving users unprovisioned). - Election-mode replica set: every time this node's leadership hook runs, right after `ensureRootUser` — i.e. on every election, not just the first one. This is intentionally - idempotent: `createUser` on a name that already exists falls back to `updateUser` (password, - roles and mechanisms from the file replace the stored state), so repeated leadership changes - (flapping, priority takeover) just re-apply harmlessly. A failure here can only be **logged** + idempotent: `createUser` on a name that already exists falls back to `updateUser` (password + and roles from the file replace the stored state; `mechanisms`, when listed in the file, + replaces the stored set too — but when the file entry OMITS `mechanisms`, an existing user + keeps whatever mechanism set they already have, mongod's `updateUser` semantics. A user first + provisioned with `mechanisms: ["SCRAM-SHA-256"]` therefore stays SHA-256-only even if a later + file version drops the key; to get back to the default pair, list both mechanisms explicitly), + so repeated leadership changes (flapping, priority takeover) just re-apply harmlessly. A failure here can only be **logged** (`ERROR`) — a running server cannot abort mid-failover, so the node keeps serving with whatever user state it already had. - A static-mode **secondary** never applies the file locally, even if `--users-file` is configured on it too (PoppyDB logs an INFO line noting that it is ignored there) — it receives the result purely through the normal `admin.system.users` replication that already carries - `createUser`/`updateUser` writes (see [User replication](#authentication---auth) above). The + `createUser`/`updateUser` writes (see [User replication](#authentication-auth) above). The file is only ignored for *application* on such a node — it is still parsed and validated at startup like everywhere else, so a syntactically broken file fails that node's startup too (fail-fast by design, not a live-apply attempt). @@ -707,9 +715,10 @@ server.setBootstrapUsers(UsersFileLoader.load("/etc/poppydb/users.json")); server.start(); ``` -**Out of scope (by design):** there is no `dropUser`/reconciliation-delete — the file only ever -adds/updates, so removing a user still means an explicit `dropUser` (or leaving them in the file -with a rotated password is not equivalent to removal); no role *enforcement* (same limitation as +**Out of scope (by design):** the file has no reconciliation-delete — it only ever adds/updates, +so removing a user means an explicit `dropUser` command against the primary (which replicates +like any other user write; merely deleting the entry from the file does NOT remove the user); +no role *enforcement* (same limitation as `createUser`'s `roles` field everywhere else); no environment-variable substitution inside the file; and no file-watching — a changed file only takes effect on the next apply (restart, or the next leadership change in election mode), never live. @@ -731,6 +740,39 @@ PoppyDB server = new PoppyDB(); ## Connecting Clients +### Capabilities document and driver settings + +The `hello` reply carries a `poppyCapabilities` document describing what PoppyDB honestly +supports, so clients and tooling can adapt instead of discovering gaps at runtime: + +```json +"poppyCapabilities": { + "version": 1, + "retryableWrites": false, + "journal": false, + "durability": "snapshot", + "readConcern": "local", + "transactions": "partial", + "textSearch": "simplified" +} +``` + +Practical consequences for non-Morphium drivers: + +- **Set `retryWrites=false` in the connection string.** PoppyDB advertises a replica set and + logical sessions, which makes modern drivers enable retryable writes by default — but + PoppyDB has no `(lsid, txnNumber)` deduplication yet, so a driver-side retry after a lost + acknowledgement would apply the write twice. (True retryable-write support is specced in + issue #293.) +- **`j: true` write concerns fail honestly** with `writeConcernError` code 2 (`BadValue`), + like mongod running without journaling: PoppyDB persists via periodic snapshots, there is + no journal to wait for. The write itself is still executed. +- **Reads on a secondary require an explicit read preference.** MongoDB's default read + preference is `primary`, so a read without `$readPreference` is rejected on a secondary + with `NotPrimaryNoSecondaryOk` (13435) — the same way mongod treats a direct secondary + connection without `secondaryOk`. Morphium's own driver always sends a read preference + (default `primaryPreferred`) and is unaffected. + ### Java (Morphium) ```java @@ -990,7 +1032,7 @@ sessions automatically, replica-set failover keeps sessions alive across node re Session) work unchanged. `$inc` + TTL also cover rate limiting and counters; tiny config/feature-flag collections get instant propagation via change streams. -For all production use: enable [`--auth`](#authentication---auth) (note that roles are +For all production use: enable [`--auth`](#authentication-auth) (note that roles are not evaluated yet — isolate the network segment), size the heap deliberately, monitor `db.serverStatus().memoryWatermark` and `db.stats()`, and read the loss model above. @@ -1213,7 +1255,7 @@ db.watch().on('change', console.log); ### Security - ✅ **TLS/SSL Supported** - Encrypted connections available (since v6.1.0) - ✅ **Authentication** - Real SCRAM-SHA-1/SHA-256, opt-in via `--auth` (since v6.3.0) - see - [Authentication](#authentication---auth) + [Authentication](#authentication-auth) - ⚠️ **Authorization not enforced** - roles are stored (`createUser`'s `roles` field) but not evaluated; any authenticated user may run any command. Isolate the network segment if you need fine-grained access control. diff --git a/docs/production-deployment-guide.md b/docs/production-deployment-guide.md index d5c96a1d1..08d3a58e7 100644 --- a/docs/production-deployment-guide.md +++ b/docs/production-deployment-guide.md @@ -60,10 +60,9 @@ cfg.authSettings().setMongoAdminPwd(System.getenv("MONGO_ADMIN_PWD")); **Network Security:** ```java -// Note: Wire protocol driver has limitations -// - No MongoDB Atlas support -// - No SSL/TLS connections -// Deploy in trusted network environments or use network-level encryption +// SSL/TLS and MongoDB Atlas (mongodb+srv://) are supported since v6.0/v6.2 — +// see docs/ssl-tls.md for setup, including the Atlas example. +cfg.connectionSettings().setUseSSL(true); ``` ### 3. Environment-Specific Configurations diff --git a/docs/quarkus-extension.md b/docs/quarkus-extension.md new file mode 100644 index 000000000..a53bd5d93 --- /dev/null +++ b/docs/quarkus-extension.md @@ -0,0 +1,185 @@ +# Quarkus Extension: CDI Integration for Morphium + +`quarkus-morphium` is an **optional Morphium module** that integrates Morphium into +[Quarkus](https://quarkus.io) applications via a CDI producer, type-safe +`@ConfigMapping` configuration, declarative transactions, health checks, Dev Services, +Dev UI integration, and GraalVM native-image support. It also pulls in +[`morphium-jakarta-data`](jakarta-data.md) and generates Jakarta Data `@Repository` +implementations at **build time** via Gizmo bytecode generation — no runtime +reflection, no dynamic proxies. + +!!! note "Optional module — the Morphium core does not depend on it" + `de.caluga:morphium` has zero compile- or runtime dependency on this extension, on + Quarkus, or on `jakarta.data-api`. Building the Morphium reactor without this module + (`-DskipExtensions`) produces an unchanged core. You only need `quarkus-morphium` if + you are building a Quarkus application against MongoDB via Morphium. + +## What it provides + +- **CDI producer** — `@Inject Morphium morphium;` anywhere in a Quarkus bean, backed by + a single, application-scoped `Morphium` instance configured from + `application.properties`. +- **Type-safe configuration** — every setting lives under `quarkus.morphium.*` as a + `@ConfigMapping`, validated at build time instead of failing at runtime on a typo. +- **Declarative transactions** — `@MorphiumTransactional` on a CDI bean method wraps + the method body in `startTransaction()`/`commitTransaction()`/`abortTransaction()` + automatically, with `MorphiumTransactionEvent` CDI events (`BEFORE_COMMIT`, + `AFTER_COMMIT`, `AFTER_ROLLBACK`) for cross-cutting reactions (audit logging, + outbox publishing, etc.). Gracefully degrades to non-transactional execution on + Azure CosmosDB, which is auto-detected. +- **Jakarta Data repositories** — declare a `@Repository` interface extending + `CrudRepository`/`MorphiumRepository` from `morphium-jakarta-data`; the extension's + build-time processor generates the implementation via Gizmo, with no reflection at + runtime. See [Jakarta Data](jakarta-data.md) for the full query-derivation, JDQL, and + pagination feature set — everything documented there works identically once + generated by this extension. +- **Health checks** — MicroProfile liveness (`/q/health/live`), readiness + (`/q/health/ready`, with connection-pool metadata), and startup (`/q/health/started`) + probes registered automatically via SmallRye Health. +- **Dev Services** — a MongoDB container (optionally as a single-node replica set, so + transactions and change streams work out of the box) starts automatically in dev and + test mode when no explicit `quarkus.morphium.hosts` is configured — no Docker Compose, + no manual setup. +- **Dev UI card** — live MongoDB connection info (hosts, database, replica-set mode, + container ID) at `/q/dev-ui/`. +- **GraalVM native-image support** — every `@Entity`/`@Embedded` class (and Morphium's + own reflection-dependent internals) is registered for reflection at build time; no + manual `reflect-config.json`. +- **`MorphiumId` JSON serialization** — entities with `@Id MorphiumId id` serialize to + a plain 24-character hex string over REST (both Jackson and JSON-B), and parse back + from one — no serializer to write by hand. +- **Migration runner** — a lightweight, MongoDB-backed schema/data migration mechanism + (`quarkus.morphium.migration.*`) with a distributed lock, so multiple application + instances don't race to apply the same migration. + +## Installation + +```xml + + de.caluga + quarkus-morphium + ${project.version} + +``` + +In the Morphium reactor, `${project.version}` resolves to whatever version the reactor is +currently on (see the root `pom.xml`). This module follows Morphium's regular release +versioning; there is no separate version line to track — building the reactor +(`mvn -pl quarkus-morphium -am verify`) builds this extension against the exact Morphium +core version in the same build. + +## Configuration Reference + +All properties live under `quarkus.morphium.*`. This is not the complete list — see the +Antora documentation in the module directory (`quarkus-morphium/docs/`) for every +property — but covers the most commonly used ones, each verified directly against the +`@ConfigMapping` source. + +| Property | Default | Description | Source | +|---|---|---|---| +| `quarkus.morphium.hosts` | `localhost:27017` | Comma-separated `host:port` list | `MorphiumRuntimeConfig.java:52` | +| `quarkus.morphium.database` | *(required)* | MongoDB database name | `MorphiumRuntimeConfig.java:55` | +| `quarkus.morphium.username` / `.password` | -- | Optional credentials | `MorphiumRuntimeConfig.java:58,61` | +| `quarkus.morphium.auth-database` | `admin` | Authentication database | `MorphiumRuntimeConfig.java:65` | +| `quarkus.morphium.read-preference` | `primary` | Read preference | `MorphiumRuntimeConfig.java:73` | +| `quarkus.morphium.index-check` | `create-on-startup` | Index management strategy (`create-on-startup`, `warn-on-startup`, `create-on-write-new-col`, `no-check`) | `MorphiumRuntimeConfig.java:92` | +| `quarkus.morphium.max-connections` | `250` | Connection pool size | `MorphiumRuntimeConfig.java:108` | +| `quarkus.morphium.max-wait-time` | `2000` | Max wait time (ms) for a pooled connection | `MorphiumRuntimeConfig.java:118` | +| `quarkus.morphium.default-query-timeout-ms` | `0` (disabled) | Server-side `maxTimeMS` applied to queries without an explicit per-query timeout | `MorphiumRuntimeConfig.java:133` | +| `quarkus.morphium.atlas-url` | -- | MongoDB Atlas SRV connection string (overrides `hosts`) | `MorphiumRuntimeConfig.java:139` | +| `quarkus.morphium.driver-name` | `PooledDriver` | `PooledDriver` (production) or `InMemDriver` (tests, no MongoDB needed) | `MorphiumRuntimeConfig.java:146` | +| `quarkus.morphium.replica-set-name` | -- | MongoDB replica set name (required for transactions) | `MorphiumRuntimeConfig.java:153` | +| `quarkus.morphium.connect-retries` | `5` | Connection attempts before giving up | `MorphiumRuntimeConfig.java:162` | +| `quarkus.morphium.cache.read-cache-enabled` | `true` | Enable query result cache | `CacheConfig.java:31` | +| `quarkus.morphium.cache.global-valid-time` | `60000` | Cache TTL in milliseconds | `CacheConfig.java:27` | +| `quarkus.morphium.local-date-time.use-bson-date` | -- | Store `LocalDateTime` as BSON `ISODate` | `LocalDateTimeConfig.java` | +| `quarkus.morphium.ssl.enabled` | `false` | Enable TLS | `SslConfig.java:48` | +| `quarkus.morphium.ssl.auth-mechanism` | -- | `MONGODB-X509` for client-certificate auth | `SslConfig.java:59` | +| `quarkus.morphium.ssl.keystore-path` / `.keystore-password` | -- | Keystore for client-cert auth / mutual TLS | `SslConfig.java:65,68` | +| `quarkus.morphium.ssl.truststore-path` / `.truststore-password` | -- | Truststore for server certificate validation | `SslConfig.java:74,77` | +| `quarkus.morphium.ssl.invalid-hostname-allowed` | `false` | Allow invalid hostnames (dev only) | `SslConfig.java:84` | +| `quarkus.morphium.ssl.tls-configuration-name` | -- | Use a named Quarkus TLS registry configuration instead of explicit keystore/truststore paths | `SslConfig.java:108` | +| `quarkus.morphium.devservices.enabled` | `true` | Enable automatic MongoDB container in dev/test mode | `MorphiumDevServicesBuildTimeConfig.java:45` | +| `quarkus.morphium.devservices.image-name` | `mongo:8` | Docker image for Dev Services | `MorphiumDevServicesBuildTimeConfig.java:52` | +| `quarkus.morphium.devservices.database-name` | `morphium-dev` | Database name injected by Dev Services | `MorphiumDevServicesBuildTimeConfig.java:59` | +| `quarkus.morphium.devservices.replica-set` | `true` | Start MongoDB as a single-node replica set (enables transactions) | `MorphiumDevServicesBuildTimeConfig.java:72` | +| `quarkus.morphium.health.enabled` | `true` | Enable liveness/readiness/startup health checks | `MorphiumHealthBuildTimeConfig.java:41` | +| `quarkus.morphium.migration.migrate-at-start` | `false` | Run pending migrations automatically on startup | `MorphiumMigrationConfig.java:40` | +| `quarkus.morphium.migration.change-log-collection` | `morphiumChangeLog` | Collection tracking executed migrations | `MorphiumMigrationConfig.java:44` | +| `quarkus.morphium.migration.lock-collection` | `morphiumMigrationLock` | Collection used for the distributed migration lock | `MorphiumMigrationConfig.java:48` | +| `quarkus.morphium.migration.lock-ttl-seconds` | `60` | Migration-lock TTL in seconds | `MorphiumMigrationConfig.java:56` | + +## Quick Example + +```java +@Entity(collectionName = "products") +public class Product { + @Id private MorphiumId id; + private String name; + private double price; + private String category; + @Version private long version; + // getters/setters omitted +} + +@Repository +public interface ProductRepository extends MorphiumRepository { + List findByCategory(String category); + + @OrderBy("price") + List findByPriceGreaterThan(double minPrice); +} + +@ApplicationScoped +public class ProductService { + @Inject ProductRepository products; + + @MorphiumTransactional + public Product create(String name, double price, String category) { + var p = new Product(); + p.setName(name); + p.setPrice(price); + p.setCategory(category); + return products.insert(p); + } +} +``` + +```properties +quarkus.morphium.database=my-app-db +# Dev Services starts MongoDB automatically — no further config needed in dev/test. +``` + +## Testing without Docker + +```properties +%test.quarkus.morphium.driver-name=InMemDriver +%test.quarkus.morphium.database=test-db +``` + +`InMemDriver` is Morphium's in-memory MongoDB emulation — `@QuarkusTest` classes run +against it with no container and no external MongoDB, exactly like the core Morphium +test suite. + +## Full Documentation + +This page is an overview. The complete documentation — getting started, entity +mapping, configuration reference, transactions, health checks, Dev Services, Jakarta +Data repositories, testing, and advanced topics — lives as an [Antora](https://antora.org) +documentation module in the repository, at `quarkus-morphium/docs/` (source pages under +`quarkus-morphium/docs/modules/ROOT/pages/`): + +[`quarkus-morphium/docs/modules/ROOT/pages/`](https://github.com/sboesebeck/morphium/tree/develop/quarkus-morphium/docs/modules/ROOT/pages) + +!!! note "Antora docs are not part of this site's build" + This MkDocs site (the pages under `docs/`, including this one) and the Antora + documentation under `quarkus-morphium/docs/` are two separate, coexisting + toolchains — the Antora source is not currently built or published by this + repository's `deploy-docs.yml` workflow. Until a publishing decision is made, + browse the Antora pages directly on GitHub via the link above, or render them + locally with the [Antora CLI](https://docs.antora.org/antora/latest/) from + `quarkus-morphium/docs/antora.yml`. + +See also [Jakarta Data](jakarta-data.md) for the framework-agnostic repository runtime +that this extension builds on, and [PoppyDB](poppydb.md) for Morphium's other optional +module. diff --git a/docs/quickstart-tutorial.md b/docs/quickstart-tutorial.md index 8a9b6f726..04df6ff09 100644 --- a/docs/quickstart-tutorial.md +++ b/docs/quickstart-tutorial.md @@ -297,7 +297,7 @@ You can now: **Continue with:** - [Write Your First Test](./first-test.md) -- [Annotations in Detail](./developer-guide.md#annotations) +- [Annotations in Detail](./api-reference.md#annotation-reference) - [Using Messaging](./messaging.md) --- diff --git a/docs/security-guide.md b/docs/security-guide.md index 773eeed27..aed73da0d 100644 --- a/docs/security-guide.md +++ b/docs/security-guide.md @@ -142,7 +142,7 @@ java -jar poppydb-cli.jar -p 27018 --auth --rootUser admin --rootPassword s3cr3t ``` Note that authorization is authentication-only for now: roles are stored but not evaluated, -and `createRole` is not implemented. See the [PoppyDB documentation](poppydb.md#authentication---auth) +and `createRole` is not implemented. See the [PoppyDB documentation](poppydb.md#authentication-auth) for details, client examples and limitations. ## MONGODB-X509 Certificate Authentication diff --git a/docs/spring-boot.md b/docs/spring-boot.md new file mode 100644 index 000000000..68986ce91 --- /dev/null +++ b/docs/spring-boot.md @@ -0,0 +1,307 @@ +# Spring Boot Starter: Auto-Configuration for Morphium + +`morphium-spring-boot-*` is an **optional Morphium module** that integrates Morphium +into [Spring Boot](https://spring.io/projects/spring-boot) applications via +auto-configuration, type-safe `@ConfigurationProperties`, declarative transactions, +an Actuator health indicator, and Jakarta Data `@Repository` interfaces backed by JDK +dynamic proxies at runtime — no build-time bytecode generation, no annotation +processor for the repositories themselves. It pulls in +[`morphium-jakarta-data`](jakarta-data.md) for the entire query-derivation, JDQL, and +pagination runtime. + +!!! note "Optional module — the Morphium core does not depend on it" + `de.caluga:morphium` has zero compile- or runtime dependency on this module, on + Spring, or on `jakarta.data-api`. You only need `morphium-spring-boot-starter` if + you are building a Spring Boot application against MongoDB via Morphium. + +## What it provides + +- **Auto-configuration** — `MorphiumAutoConfiguration` creates the application's + single `Morphium` bean from `morphium.*` properties, with connection retry on + transient failures (linear backoff). +- **Type-safe configuration** — every setting lives under `morphium.*` as + `@ConfigurationProperties`, with `spring-boot-configuration-processor`-generated + metadata for IDE autocompletion. +- **Jakarta Data repositories** — declare a `@Repository` interface extending + `CrudRepository`/`MorphiumRepository` from `morphium-jakarta-data`; at Spring + context-startup time, `MorphiumRepositoryRegistrar` scans for such interfaces and + registers a `MorphiumRepositoryFactoryBean` for each, which creates a + `java.lang.reflect.Proxy` implementing the interface — see + [Proxy mechanism vs. Quarkus](#proxy-mechanism-vs-quarkus) below. See + [Jakarta Data](jakarta-data.md) for the full query-derivation, JDQL, and pagination + feature set — everything documented there works identically once wired through this + module's proxies. +- **Declarative transactions** — `@MorphiumTransactional` on a Spring bean method + wraps the method body in `startTransaction()`/`commitTransaction()`/ + `abortTransaction()` via an AspectJ `@Around` advice, active only when + `spring-boot-starter-aop` is on the classpath. +- **Actuator health** — a `HealthIndicator` reporting live MongoDB connection status + (database, driver, replica-set state) under `/actuator/health`, active only when + `spring-boot-actuator` is present and a `Morphium` bean already exists. +- **Test support** — the companion `morphium-spring-boot-test` module provides + `@MorphiumTest`, a composite annotation that wires `InMemDriver` (Morphium's + in-memory MongoDB emulation) into a `@SpringBootTest`, so repository tests run + without a MongoDB instance or container. + +## Installation + +```xml + + de.caluga + morphium-spring-boot-starter + ${project.version} + +``` + +In the Morphium reactor, `${project.version}` currently resolves to `6.3.2-SNAPSHOT`. +This module follows Morphium's regular release versioning — it is versioned and +released in lockstep with Morphium; there is no separate version line to track. + +## Configuration Reference + +All properties live under `morphium.*` (not `spring.morphium.*` — the `spring.*` +namespace is reserved for Spring Boot's own configuration keys). Every entry below is +verified directly against `MorphiumProperties.java` in the +`morphium-spring-boot-autoconfigure` module. + +| Property | Default | Description | Source | +|---|---|---|---| +| `morphium.database` | *(required)* | MongoDB database name | `MorphiumProperties.java:46` | +| `morphium.hosts` | `localhost:27017` | Comma-separated `host:port` list; ignored if `morphium.atlas-url` is set | `MorphiumProperties.java:39` | +| `morphium.username` / `.password` | -- | Optional credentials, applied only when both are set | `MorphiumProperties.java:52,57` | +| `morphium.auth-database` | `admin` | Authentication database (`authSource`) | `MorphiumProperties.java:64` | +| `morphium.driver-name` | `PooledDriver` | `PooledDriver` (production) or `InMemDriver` (tests, no MongoDB needed) | `MorphiumProperties.java:71` | +| `morphium.read-preference` | `primary` | MongoDB read preference | `MorphiumProperties.java:77` | +| `morphium.max-connections` | `250` | Connection pool size | `MorphiumProperties.java:82` | +| `morphium.atlas-url` | -- | MongoDB Atlas SRV connection string (overrides `morphium.hosts` when set) | `MorphiumProperties.java:89` | +| `morphium.replica-set-name` | -- | Replica set name (required for transactions) | `MorphiumProperties.java:97` | +| `morphium.connect-retries` | `5` | Connection attempts before giving up on transient failures, linear backoff `attempt * 2000`ms | `MorphiumProperties.java:106` | +| `morphium.index-check` | `CREATE_ON_STARTUP` | `CREATE_ON_STARTUP`, `WARN_ON_STARTUP`, `CREATE_ON_WRITE_NEW_COL`, `NO_CHECK` | `MorphiumProperties.java:115` | +| `morphium.cache.global-valid-time` | `5000` | Cache TTL in milliseconds | `MorphiumProperties.java:361` | +| `morphium.cache.read-cache-enabled` | `true` | Enable query result cache | `MorphiumProperties.java:368` | +| `morphium.ssl.enabled` | `false` | Enable TLS | `MorphiumProperties.java:418` | +| `morphium.ssl.keystore-path` / `.keystore-password` | -- | Keystore (JKS/PKCS12) for client-certificate TLS | `MorphiumProperties.java:426,431` | + +If `spring-boot-configuration-processor` is on the classpath (declared as an optional +dependency of `morphium-spring-boot-autoconfigure`), every property above also appears +in `META-INF/spring-configuration-metadata.json`, giving IDEs autocompletion and +validation for `morphium.*` keys. + +## Quick Example + +```java +@Entity(collectionName = "products") +public class Product { + @Id private MorphiumId id; + private String name; + private double price; + private String category; + // getters/setters omitted +} + +@Repository +public interface ProductRepository extends MorphiumRepository { + List findByCategory(String category); + + List findByPriceGreaterThan(double minPrice); +} + +@SpringBootApplication +@EnableMorphiumRepositories +public class MyApplication { + public static void main(String[] args) { + SpringApplication.run(MyApplication.class, args); + } +} + +@Service +public class ProductService { + @Autowired ProductRepository products; + + public List findExpensive(double minPrice) { + return products.findByPriceGreaterThan(minPrice); + } +} +``` + +```properties +morphium.database=my-app-db +morphium.hosts=localhost:27017 +``` + +## Repository Usage + +Annotate a `@SpringBootApplication` (or any `@Configuration` class) with +`@EnableMorphiumRepositories` to enable scanning. By default the scan covers the +annotated class's package and sub-packages; pass explicit packages via `value()`/ +`basePackages()` to scan elsewhere. + +Repository interfaces extend either `jakarta.data.repository.CrudRepository` +(the plain Jakarta Data interface) or `de.caluga.morphium.data.MorphiumRepository`, which adds Morphium-specific escape hatches with no Jakarta Data equivalent: + +```java +// Distinct values for a field +List categories = products.distinct("category"); + +// Direct access to the Morphium API +products.morphium().inc(product, "stock", 5); + +// A typed Morphium Query, for anything beyond derived queries/JDQL/@Find +Query q = products.query(); +q.f("price").gt(100).f("category").eq("electronics"); +``` + +### Proxy mechanism vs. Quarkus + +This module uses **JDK dynamic proxies at runtime** — the standard Spring Data +pattern — in contrast to the [Quarkus extension](quarkus-extension.md), which uses +**Gizmo bytecode generation at build time**. + +Concretely: at Spring context-startup time, `MorphiumRepositoryRegistrar` (imported by +`@EnableMorphiumRepositories`) scans the configured base packages for `@Repository` +interfaces and registers a `MorphiumRepositoryFactoryBean` bean definition for each +one found. Each factory bean creates a `java.lang.reflect.Proxy` implementing the +repository interface, backed by a `MorphiumRepositoryInvocationHandler` that +dispatches every method call — derived queries, JDQL, `@Find`/`@Delete`, plain CRUD — +to the shared `morphium-jakarta-data` runtime. No implementation class is ever +generated or compiled; the proxy is synthesized by the JVM itself, once per repository +interface, the first time the bean is requested. + +Quarkus's `quarkus-morphium` extension instead runs a build-time processor that emits +a real, compiled implementation class via Gizmo bytecode generation before the +application ever starts — no proxy or reflective dispatch exists at runtime there at +all. The trade-off is the classic one: this module's proxies need zero build-time +tooling and work with plain `javac`, at the cost of a small amount of per-call +reflective dispatch overhead and no build-time validation of query derivation; +Quarkus's build-time generation avoids that runtime cost and validates earlier, at the +cost of requiring its build-time augmentation phase. Both mechanisms delegate to the +exact same `morphium-jakarta-data` query engine — only *how* a repository interface is +wired to that engine differs. + +## Transactions + +Requires a MongoDB replica set or Atlas cluster (`morphium.replica-set-name`) — a +standalone MongoDB node rejects multi-document transactions. + +```java +@Service +public class OrderService { + @Autowired Morphium morphium; + + @MorphiumTransactional + public void placeOrder(Order order, Payment payment) { + morphium.store(order); + morphium.store(payment); + // committed automatically on return, rolled back automatically on exception + } +} +``` + +`@MorphiumTransactional` is picked up by an AspectJ `@Around` advice +(`MorphiumTransactionAspect`) that is only active when `spring-boot-starter-aop` is on +the classpath and a `Morphium` bean exists in the context. It starts a transaction +before the advised method runs, commits on normal return, and aborts (rethrowing the +original exception unchanged) if the method throws. + +## Health / Actuator + +When `spring-boot-actuator` is on the classpath and a `Morphium` bean already exists, +`MorphiumHealthAutoConfiguration` registers a `HealthIndicator` under +`/actuator/health`: + +```json +{ + "components": { + "morphium": { + "status": "UP", + "details": { + "database": "my-app-db", + "driver": "PooledDriver", + "replicaSet": true, + "replicaSetName": "rs0" + } + } + } +} +``` + +Disable it with `management.health.morphium.enabled=false`, or override it entirely +by defining your own `@Bean(name = "morphiumHealthIndicator") HealthIndicator` — the +auto-configured bean backs off via `@ConditionalOnMissingBean(name = +"morphiumHealthIndicator")`. + +## Testing without a MongoDB instance + +```properties +# src/test/resources/application-test.properties +morphium.database=test +morphium.driver-name=InMemDriver +``` + +```java +@SpringBootTest +@ActiveProfiles("test") +@EnableMorphiumRepositories +class ProductRepositoryTest { + @Autowired ProductRepository repository; + + @Test + void shouldFindByCategory() { + repository.save(new Product("Widget", 9.99, "tools")); + assertThat(repository.findByCategory("tools")).hasSize(1); + } +} +``` + +The companion `morphium-spring-boot-test` module wraps the same properties into a +composite `@MorphiumTest` annotation: + +```java +@MorphiumTest +@EnableMorphiumRepositories +class ProductRepositoryTest { + @Autowired ProductRepository repository; + // InMemDriver is auto-configured — no MongoDB instance or container needed +} +``` + +`InMemDriver` is Morphium's in-memory MongoDB emulation — tests run against it with no +container and no external MongoDB, exactly like the core Morphium test suite. + +## Distinction from Spring Data MongoDB + +This module is **not** a replacement for, or a re-implementation of, Spring Data +MongoDB, and does not aim to be API-compatible with it: + +- It implements the **Jakarta Data 1.0** specification (`@Repository`, + `CrudRepository`, `@Find`, `@Query`/JDQL, `Page`/`CursoredPage`, `Sort`/`Order`) — a + vendor-neutral Jakarta EE specification — not Spring Data's own repository + interfaces or query-method conventions. +- The underlying data access is always **Morphium**, not Spring Data MongoDB's + `MongoTemplate`/`MongoOperations`. There is no `MongoTemplate` bean and no Spring + Data MongoDB entity mapping; entities use Morphium's own annotations (`@Entity`, + `@Id`, `@Reference`, etc.). +- Transactions are Morphium transactions wrapped by a small AOP aspect, not Spring's + `PlatformTransactionManager`/`@Transactional` infrastructure. +- Query derivation, JDQL, and pagination/sorting behavior come from + `morphium-jakarta-data`; the keyword set and grammar differ in detail from Spring + Data's query-method conventions, even though simple method names + (`findByCategory`, `countByStatus`, ...) often look similar. + +If your application already uses Spring Data MongoDB and does not use Morphium, this +module has nothing to offer you. If you are building on Morphium and want a +Spring-managed, dependency-injected repository layer with Jakarta Data semantics, this +is the module for that. + +## Full Documentation + +This page is an overview. The complete module documentation — installation, the full +property reference, repository usage, transactions, testing, and the detailed +architecture comparison with Quarkus — lives in the module's own README: + +[`spring-boot-morphium/README.md`](https://github.com/sboesebeck/morphium/blob/develop/spring-boot-morphium/README.md) + +See also [Jakarta Data](jakarta-data.md) for the framework-agnostic repository runtime +this module builds on, and [Quarkus Extension](quarkus-extension.md) for the +build-time-bytecode alternative to this module's runtime JDK proxies. diff --git a/docs/v5-vs-v6-performance.md b/docs/v5-vs-v6-performance.md index 0eb9ea705..1dbd1a480 100644 --- a/docs/v5-vs-v6-performance.md +++ b/docs/v5-vs-v6-performance.md @@ -39,6 +39,137 @@ > **Key insight:** PoppyDB is 2.5x faster than real MongoDB for messaging tests! +These are **round-trip** numbers: complete ping-pongs (request out, response received). +PoppyDB's edge here is latency — with less than half the per-message round-trip time, the +same workload completes 2.5x faster. + +> **Re-measured 2026-08-07** (Morpheus `latency --headless`, 100 msg/s fixed rate, 5 sender +> threads, 30 s measured after 10 s warmup, Mac Studio M1 Ultra client; PoppyDB = local +> 3-node replica set, MongoDB = the 3-node homelab replica set): median RTT **2.4 ms** +> against PoppyDB vs **5.7 ms** against MongoDB; averages 2.5 ms vs 7.9 ms — MongoDB's mean +> carries a fat majority-fsync tail (p99 70–128 ms), PoppyDB's p99 stays under 5 ms. A +> same-session A/B against the pre-optimization baseline attributes **8–18 % lower median +> RTT** to the 2026-08 messaging optimizations (answers dispatched before the +> `processed_by` write; non-exclusive messages processed straight from the change-stream +> `fullDocument`): PoppyDB p50 2.89 → 2.42 ms, MongoDB p50 6.23 → 5.1–5.7 ms. Beware the +> cold-start trap when reproducing: the very first run after server start measures JIT, not +> the code — discard it (ours read 2× slower than the warm steady state). The table above +> keeps the original serial-ping-pong figures; both setups measure the same path under +> different load profiles, so compare within a vintage, not across. +> +> **Topology caveat for the 2026-08-07 run:** it is not like-for-like. PoppyDB ran locally +> on the client machine while MongoDB was reached over the network (homelab, via VPN), so +> the ratio carries a network component that is not attributable to the broker. See the +> symmetric re-measurement below. + +> **Symmetric re-measurement 2026-08-11** — same Morpheus parameters (100 msg/s fixed rate, +> 5 sender threads, 30 s after 10 s warmup), but with every known bias removed: the client +> runs *inside* the homelab network on its own host (4 cores, no other load), and **both** +> backends are separate processes on dedicated hosts at equal network distance — PoppyDB as +> a 3-node replica set (`poppydb.fritz.box:17017-19`, no in-process advantage), MongoDB as +> the 2-node homelab replica set (`mongo1/mongo2:27017`). Two consecutive runs, 3001 pings +> each, zero loss: +> +> | | MongoDB (run 1 / 2) | PoppyDB (run 1 / 2) | +> |---|---|---| +> | p50 | 4.97 / 5.12 ms | **2.13 / 2.06 ms** | +> | avg | 6.10 / 8.34 ms | 2.41 / 2.40 ms | +> | min | 3.89 / 3.91 ms | 1.33 / 1.35 ms | +> | p90 | 6.80 / 7.24 ms | 2.91 / 2.63 ms | +> | p99 | 42.3 / 129.4 ms | **5.5 / 6.7 ms** | +> | max | 86.0 / 214.6 ms | 40.8 / 55.4 ms | +> | jitter | 1.28 / 1.55 ms | 0.54 / 0.52 ms | +> +> The ratio comes out at **2.34× and 2.49×**, confirming the ~2.5× of the earlier runs — the +> asymmetric topology of 2026-08-07 did not manufacture the advantage. Two things the median +> hides: the tail differs by an order of magnitude (MongoDB p99 42–129 ms at a mere 100 msg/s +> on an idle cluster, PoppyDB under 7 ms), and jitter differs by 2.5–3×. For latency-critical +> request/reply the tail is the more relevant figure. + +### Exclusive request/reply — the production profile (measured 2026-08-12) + +All numbers above ride the **broadcast (non-exclusive) path**: any listener may answer, no +lock traffic. Production request/reply between services typically uses **exclusive** messages +— exactly-once processing, which costs the responder side the full lock/claim machinery +(claim write, re-fetch, `processed_by` mark, each majority-acked on MongoDB). Measured with +Morpheus `latency --exclusive` against `pong --work 5` (5 ms simulated handler work, modeling +a real consumer), same parameters as the symmetric run above (100 msg/s, 5 sender threads, +30 s recorded after 10 s warmup, two consecutive runs, ~4,000 pings each, zero loss +everywhere). Client: Mac Studio (M1 Ultra) on the same LAN segment, 0.5–0.6 ms ICMP RTT to +both brokers — equal network distance, but a different client host than the 2026-08-11 run, +so compare ratios, not absolutes, across the two sections. morphium 6.3.1 client (with the +6.3.1 topic-filter and lock-callback fixes), PoppyDB 3-node RS on a 6.3.1-era build, MongoDB +8.0.26 as the 2-data-node + arbiter homelab RS. + +| Profile | | MongoDB (run 1 / 2) | PoppyDB (run 1 / 2) | +|---|---|---|---| +| broadcast ping | p50 | 4.43 / 4.36 ms | 2.83 / 2.71 ms | +| | p99 | 88.7 / 48.1 ms | 8.0 / 7.0 ms | +| exclusive | p50 | 11.81 / 12.83 ms | **3.87 / 3.93 ms** | +| | p99 | 807.8 / 1004.9 ms | **12.2 / 15.0 ms** | +| exclusive + 5 ms work | p50 | 18.45 / 18.47 ms | **11.02 / 11.57 ms** | +| | p99 | 726.0 / 2209.3 ms | **22.5 / 23.6 ms** | + +Three observations: + +- **The exclusive flag is nearly free on PoppyDB and expensive on MongoDB.** Going from + broadcast to exclusive costs PoppyDB ~1.1 ms at the median (claim round-trip against an + in-memory server); MongoDB pays ~8 ms — the claim/mark writes are majority-acked, so the + exclusive path stacks additional majority-commit cadences on top of the delivery floor. + Median ratio between the backends grows from ~1.6× (broadcast) to ~3.2× (exclusive). +- **The exclusive tail on MongoDB is a different regime, not a bigger number.** At a mere + 100 msg/s on an otherwise idle cluster, exclusive p99 lands at 0.7–2.2 **seconds** (p90 up + to 630 ms, max 2.7 s), and the tail is unstable between consecutive runs. PoppyDB's p99 + stays at 22–24 ms with run-to-run stability. For burst-shaped incident patterns (callers + waiting hundreds of ms for tens of ms of work) the exclusive tail is the number to watch. +- **The 5 ms simulated handler work adds more than 5 ms** (PoppyDB +7 ms, MongoDB +6 ms at + the median): a busy handler delays subsequent claims of the single consumer, so queueing + briefly appears even below nominal capacity. Real deployments spread this across more + consumers. + +Topology caveat: with 2 data nodes + arbiter, the majority commit needs *both* data nodes — +a 3-data-node set can acknowledge with the faster secondary, which may soften (not remove) +the MongoDB tails. The broadcast rows are consistent with the 2026-08-11 symmetric run +above; the exclusive rows measure the same path that production sync request/reply uses. + +### Messaging One-Way Throughput (send → receipt, no replies) + +Measured 2026-08-06 with `MessagingOneWayThroughputBenchmark` (poppydb module, tag `manual`): +5000 messages, 4 sender threads, one listening receiver, clock from first send to last +receipt. Same 4-CPU test-runner LXC as the CI matrix; MongoDB is the 3-node homelab replica +set on separate hosts, PoppyDB runs in-process. + +| Backend | Host | One-way throughput | +|---------|------|--------------------| +| **MongoDB** (3-node replica set, external hosts) | 4-CPU test runner | 868 msg/s | +| **MongoDB** (3-node replica set, external hosts) | Mac Studio (M1 Ultra, 64GB) | 1100–1250 msg/s (2026-08-07) | +| **PoppyDB** (in-process) | 4-CPU test runner | 769 msg/s | +| **PoppyDB** (in-process) | MacBook Pro (M1 Max, 32GB) | 2101 msg/s | +| **PoppyDB** (in-process) | Mac Studio (M1 Ultra, 64GB) | 4300–4900 msg/s (2026-08-07) | + +> **Honest reading:** one-way throughput is write-bound, and an in-process PoppyDB shares its +> host's CPU with sender and receiver — on a small 4-core host it lands slightly *below* an +> external replica set, while on a laptop-class CPU it is well above. PoppyDB's advantage is +> round-trip latency (table above), not raw one-way throughput on constrained hardware. A +> historic "~8K msg/s" one-way figure circulated in older READMEs; it most likely stemmed +> from plain document-write throughput (compare the bulk-write numbers above), not from +> messaging with a listening receiver, and is superseded by these measurements. +> +> The M1 Max and M1 Ultra rows are different machines — in-process throughput simply scales +> with the host. An A/B run on the M1 Ultra on 2026-08-07 (baseline vs. the 2026-08 +> optimization round: O(1) duplicate-`_id` insert pre-check, dead messaging index removed, +> `fullDocument` fast path) showed **no** significant change on this benchmark — with a +> near-empty collection, throughput is bound by the per-collection write lock, exactly as +> the write-concurrency plateau predicts. The same A/B against the MongoDB replica set +> (Mac Studio client, 2026-08-07) is also flat: there the benchmark is sender-bound +> (sendRate ≈ endToEndRate — four threads doing synchronous majority-acked inserts over the +> network), and the receiver-side re-read the `fullDocument` fast path removes shows up as +> delivery latency, not one-way throughput. Its effect belongs to the round-trip table +> above — hence the re-measurement note there. What the optimization round *did* change: +> insert cost no longer grows with collection size. Single-document inserts into a +> collection pre-filled with 200K documents went from ~97 inserts/s (per-insert O(N) `_id` +> scan) to ~205,000 inserts/s (O(1) index lookup) in the same A/B setup. + ### $in Query: Indexed vs Non-Indexed | Field | MongoDB | InMemory | diff --git a/docs/why-morphium.md b/docs/why-morphium.md index d9f1209bf..cd0d969b2 100644 --- a/docs/why-morphium.md +++ b/docs/why-morphium.md @@ -31,23 +31,56 @@ User user = collection.find(eq("username", "alice")).first(); **Problems:** - **Complex configuration** — Codec Registry setup is non-trivial -- **Limited control** — Little influence over mapping behavior +- **Limited control** — no first-class support for lifecycle hooks, lazy `@Reference` loading, + field-level encryption, or custom name providers; you get whatever the codec conventions expose + and no more - **Conflicts with other mappers** — The driver "wants" to map itself, which can lead to **double mapping** when integrating with other frameworks -- **No caching integration** — You have to build caching yourself +- **No caching integration — and that's a real gap, not a minor one** — the driver gives you no + hook into the write path, no distributed invalidation mechanism, and no deterministic cache-key + generation for queries. Replicating what Morphium gives you for free (`@Cache` per entity, + `MessagingCacheSynchronizer`/`WatchingCacheSynchronizer` for cluster-wide invalidation, a + query-result cache keyed by criteria+sort+projection+paging — see the + [caching docs](./developer-guide.md#cache-synchronization)) means building, yourself: a wrapper + around every store/update/delete to know when to invalidate, a way to propagate that across a + cluster (a message queue you now also have to operate, or your own change-stream consumer with + fan-out), and a stable cache-key scheme per query shape. Most teams never build this properly — + they either accept "always hit the DB", or bolt on Redis as a second system where cache + consistency can now break independently of the database. ### Why Morphium Has Its Own Driver (since v5.0) -The official driver's built-in mapping conflicted with Morphium's mapping: -- Double mapping (performance loss) -- Unexpected type conversions -- Hard-to-debug errors +Running the official driver's built-in POJO mapping *underneath* Morphium's own ODM mapping meant +mapping every document twice, with two independently-opinionated mappers fighting over the same +object graph: +- Double mapping (real work done twice, not just a "the codec itself is slow" issue) +- Unexpected type conversions where the two mappers disagreed +- Hard-to-debug errors from that disagreement -**The solution:** A custom wire-protocol driver, **tailored exactly to Morphium's needs**. +Note: this is an *integration* problem, not a claim that the official driver's own mapper is slow +in isolation — it isn't, and older claims here about generics support/mapping speed being weak +points of the official driver no longer hold and shouldn't be used as arguments. + +**The solution:** A custom wire-protocol driver, **tailored exactly to Morphium's needs**, avoiding +the double-mapping problem entirely since there's only one mapper in the picture. **Benefits of the custom driver:** -- **Lightweight** — Only what Morphium needs, no overhead -- **Full control** — Mapping, retry, failover by our rules -- **InMemory Driver possible** — The lean driver made a complete in-memory implementation practical +- **Failover, on our terms** — the official driver's failover behavior caused real production + issues; owning the wire protocol means Morphium controls retry/reconnect/failover semantics + directly instead of working around someone else's. +- **No double mapping** — a single object-mapping layer, tightly integrated with Morphium's + lifecycle callbacks, `@Reference` lazy loading, `@Encrypted` fields, and custom type mappers, + instead of two mappers fighting over the same document. +- **InMemoryDriver** — owning the driver abstraction (`MorphiumDriver`) made a pure-Java, + no-network in-memory implementation practical; most of the test suite runs against it, no + MongoDB or Testcontainers required. +- **PoppyDB** — a self-contained, wire-protocol-compatible alternative server exists only because + Morphium isn't tied to the official driver's internals or assumptions. +- **One abstraction, three interchangeable backends** — the same `MorphiumDriver` interface runs + against real MongoDB, PoppyDB, and the InMemoryDriver, which wouldn't be possible wrapping a + driver designed around exactly one server implementation. +- **Wire-level control** — e.g. BSON's 16MB message limit is enforced end-to-end with a custom + batch splitter; messaging (a MongoDB-collection-based pub/sub) is built directly on top of the + same driver layer instead of bolted onto a black-box client. --- @@ -183,14 +216,14 @@ public class Product { } ``` -Morphium caches automatically locally. For **cluster-wide synchronization**, you need a `CacheSynchronizer`: +Morphium caches automatically locally. For **cluster-wide synchronization**, attach a cache synchronizer: ```java // Enable cache synchronization in cluster -CacheSynchronizer cacheSynchronizer = new CacheSynchronizer(messaging, morphium); +MessagingCacheSynchronizer cacheSynchronizer = new MessagingCacheSynchronizer(messaging, morphium); ``` -The CacheSynchronizer uses the messaging system to propagate cache invalidations to all instances. No Redis/Memcached setup needed — just Morphium's own messaging. +`MessagingCacheSynchronizer` uses Morphium's own messaging to propagate cache invalidations to all instances — no Redis/Memcached setup needed. There's also a `WatchingCacheSynchronizer`, which watches the underlying collections directly via MongoDB Change Streams instead of relying on messaging (trade-offs and a "which one" guide are in the [Developer Guide](./developer-guide.md#cache-synchronization)). --- @@ -220,7 +253,7 @@ void setup() { Need the same in-memory engine reachable over the network — for multi-language integration tests, CI pipelines, or as a lightweight production message broker/cache (no Docker or MongoDB install -required)? That's **[PoppyDB](../poppydb.md)**: the InMemory Driver exposed behind the real +required)? That's **[PoppyDB](poppydb.md)**: the InMemory Driver exposed behind the real MongoDB wire protocol, so any MongoDB client (Python, Node.js, Go, ...) can connect to it directly. See the [Production Deployment Playbook](./howtos/poppydb-deployment.md) if you're running it as more than a test fixture. @@ -262,8 +295,7 @@ Let's be honest: Morphium isn't always the best choice. | Scenario | Recommendation | |----------|----------------| -| MongoDB Atlas | **Official Driver** (Morphium doesn't support Atlas) | -| Maximum throughput (>50K ops/sec) | **Official Driver** (less overhead) | +| Need the official driver's full feature surface on day one (GridFS, every admin/aggregation operator) | **Official Driver** — Morphium's own wire-protocol driver covers a subset, see [SSL/TLS guide](./ssl-tls.md) and driver docs for what's supported | | Team only knows Spring Data | **Spring Data MongoDB** (lower learning curve) | | No messaging needed, simple CRUD | **Official Driver** is sufficient | | Already have RabbitMQ/Kafka in stack | Messaging advantage disappears | diff --git a/docs/wire-proxy.md b/docs/wire-proxy.md new file mode 100644 index 000000000..6867029c2 --- /dev/null +++ b/docs/wire-proxy.md @@ -0,0 +1,259 @@ +# Wire Proxy — Fault Injection & Wire-Level Monitoring + +Morphium's test sources ship a small, reusable TCP proxy for the MongoDB wire protocol: +`WireProxy`. It sits between any wire-protocol client (Morphium, the official drivers, +`mongosh`) and any wire-protocol backend (MongoDB **or** PoppyDB) and gives you three things +that are otherwise hard to get in a test: + +1. **Fault injection** — freeze, reset, or cleanly close connections at runtime, without + touching the server process. This is how `DriverFailoverProxyTest` reproduces failovers + (clean stepdown, hard kill, frozen socket) in the normal CI matrix, with no `kill -9` and + no hand-built infrastructure. +2. **Wire-level monitoring** — observe every server response frame as a parsed + `WireProtocolMessage`, e.g. to log exactly what a server sends during a test, or to assert + on protocol-level behavior your API-level test can't see. +3. **Response manipulation** — rewrite server replies before the client sees them: change + topology information (that is how the failover suite works), mutate documents, or inject + deliberately malformed replies to test client robustness. + +It lives in `morphium-core`'s **test** sources — package +`de.caluga.test.morphium.testutil.proxy` — so it is available to every test in this repository. +It is not (yet) published as a standalone artifact; if you want to use it outside this repo, +copy the package (it has no dependencies beyond `WireProtocolMessage`) or open an issue. + +## Quick start + +```java +// Proxy in front of any wire-protocol server (MongoDB or PoppyDB) +WireProxy proxy = new WireProxy("localhost", 27017); +proxy.addObserver(new Slf4jFrameObserver()); // log every server response frame +proxy.start(); + +// Point the client at the proxy, not the server +MorphiumConfig cfg = new MorphiumConfig(); +cfg.clusterSettings().setHostSeed("localhost:" + proxy.getListenPort()); + +// ... run the test ... + +proxy.stop(); // severs all connections, joins every pump thread before returning +``` + +`WireProxy` implements `AutoCloseable`, so try-with-resources works too. `stop()` guarantees +that every internal pump thread has exited before it returns — no thread leakage across tests. + +## Full example: 3-node replica set, everything logged + +The fragments above show single pieces; this is the whole thing end to end — three proxies in +front of a three-node replica set, address rewriting so the driver never escapes the proxies, +an observer logging every server reply, one write and one read flowing through, and a clean +teardown. Runs as-is from `morphium-core`'s test scope (that's where `WireProxy` and +`UncachedObject` live): + +```java +import java.util.*; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.MorphiumConfig; +import de.caluga.morphium.driver.wireprotocol.OpMsg; +import de.caluga.morphium.driver.wireprotocol.WireProtocolMessage; +import de.caluga.test.mongo.suite.data.UncachedObject; +import de.caluga.test.morphium.testutil.proxy.AddressRewriter; +import de.caluga.test.morphium.testutil.proxy.WireProxy; + +public class WireProxyDemo { + + public static void main(String[] args) throws Exception { + // The RS members EXACTLY as the servers report them in hello ("Map-key invariant": + // if the server says "mongo1:27017", the key must be "mongo1:27017", not an IP). + List members = List.of("mongo1:27017", "mongo2:27017", "mongo3:27017"); + + // 1) One proxy per member, each on a random local port. + List proxies = new ArrayList<>(); + Map backendToProxy = new LinkedHashMap<>(); + for (String member : members) { + String host = member.substring(0, member.indexOf(':')); + int port = Integer.parseInt(member.substring(member.indexOf(':') + 1)); + WireProxy proxy = new WireProxy(host, port); + proxies.add(proxy); + backendToProxy.put(member, "localhost:" + proxy.getListenPort()); + } + + // 2) One shared rewriter (so every hello reply, from every node, maps the full + // topology to proxy addresses) + a logging observer on every proxy. + AddressRewriter rewriter = new AddressRewriter(backendToProxy); + for (WireProxy proxy : proxies) { + proxy.setRewriter(rewriter); + proxy.addObserver((dir, msg, ctx) -> + System.out.printf("[proxy:%d] %s %s%n", ctx.listenPort(), dir, summarize(msg))); + proxy.start(); + } + System.out.println("topology mapping: " + backendToProxy); + + // 3) Morphium gets ONLY the proxy addresses as its seed. SSL and wire compression + // must be OFF - the proxy cannot frame-parse either (deliberate non-goal). + MorphiumConfig cfg = new MorphiumConfig(); + cfg.connectionSettings().setDatabase("wireproxy_demo"); + cfg.clusterSettings().getHostSeed().clear(); + backendToProxy.values().forEach(cfg.clusterSettings()::addHostToSeed); + cfg.driverSettings().setDriverName("PooledDriver"); + cfg.clusterSettings().setHeartbeatFrequency(1000); + cfg.driverSettings().setServerSelectionTimeout(5000); + cfg.connectionSettings().setUseSSL(false); + cfg.driverSettings().setCompressionType(MorphiumConfig.CompressionType.NONE); + + // 4) Everything from here on - discovery hellos, heartbeats, the write, the read - + // shows up line by line in the observer output. + try (Morphium morphium = new Morphium(cfg)) { + morphium.store(new UncachedObject("hello through the proxy", 42)); + long count = morphium.createQueryFor(UncachedObject.class).countAll(); + System.out.println("read back through the proxies: " + count + " document(s)"); + + // Optional: watch the driver cope with a frozen node. Freeze the first proxy - + // its connections go silent (no error, no close), exactly like a paused VM. + // proxies.get(0).setFaultMode(FaultMode.freeze); + } finally { + // Severs every connection (hard RST) and joins all pump threads before returning. + for (WireProxy proxy : proxies) { + proxy.stop(); + } + } + } + + /** Compact one-liner per frame: hello replies show the (rewritten!) topology, + * everything else just its top-level keys. */ + private static String summarize(WireProtocolMessage msg) { + if (msg instanceof OpMsg op && op.getFirstDoc() != null) { + Map doc = op.getFirstDoc(); + if (doc.containsKey("hosts")) { + return "hello(primary=" + doc.get("primary") + ", hosts=" + doc.get("hosts") + ")"; + } + return "OpMsg" + doc.keySet(); + } + return msg.getClass().getSimpleName(); + } +} +``` + +Typical output — note that the `hello` lines already show **proxy** addresses, which is the +address rewriting doing its job; if a real backend address ever shows up here, your +`backendToProxy` keys don't match what the server reports: + +```text +topology mapping: {mongo1:27017=localhost:52114, mongo2:27017=localhost:52115, mongo3:27017=localhost:52116} +[proxy:52114] BACKEND_TO_CLIENT hello(primary=localhost:52114, hosts=[localhost:52114, localhost:52115, localhost:52116]) +[proxy:52115] BACKEND_TO_CLIENT hello(primary=localhost:52114, hosts=[localhost:52114, localhost:52115, localhost:52116]) +[proxy:52114] BACKEND_TO_CLIENT OpMsg[n, electionId, opTime, ok, ...] +[proxy:52114] BACKEND_TO_CLIENT OpMsg[cursor, ok, ...] +read back through the proxies: 1 document(s) +``` + +## Fault injection + +Faults are switched at runtime via `proxy.setFaultMode(...)`: + +| `FaultMode` | Existing connections | New connection attempts | Simulates | +|---|---|---|---| +| `passthrough` | forwarded normally | accepted | healthy network (default) | +| `freeze` | left open, never answered, never closed | accepted, then silence | frozen process (`kill -STOP`), network partition, paused VM — the failure a client cannot distinguish from a slow server | +| `reset` | severed with a hard RST | refused | dead process (`kill -9`), closed port | +| `close` | severed with a clean FIN | refused | this route to the node is gone (the node itself may live on — e.g. after a clean stepdown) | + +Semantics worth knowing before you build a test on them: + +- **`freeze` is one-way per connection.** A connection accepted (or already open) during a + freeze stays parked until `stop()` — switching back to `passthrough` only affects + connections opened *after* the switch. That mirrors reality: a socket to a frozen process + does not spring back to life; the client has to time out and reconnect. +- **`close` and `reset` both refuse new connections** — they differ only in how existing ones + are severed (FIN vs. RST). "Nothing reachable behind this proxy right now" is the contract. +- **`stop()` always severs with RST**, regardless of the configured fault mode, so a client + blocked in a read sees a definite error rather than a clean EOF that would look like an + orderly shutdown. + +## Replica sets: the address-rewriting trick + +A proxy per node is not enough for a replica set: drivers do server discovery via `hello`, and +the server answers with the **real** addresses (`hosts`, `primary`, `me`). After the first +`hello`, a driver would connect straight past your proxies. + +`AddressRewriter` fixes that. It is a `ResponseRewriter` that detects `hello`/`isMaster`-shaped +replies structurally (`setName` + `hosts` present) and maps every real address to its proxy +address: + +```java +// one proxy per RS member +Map backendToProxy = Map.of( + "mongo1:27017", "localhost:" + p1.getListenPort(), + "mongo2:27017", "localhost:" + p2.getListenPort(), + "mongo3:27017", "localhost:" + p3.getListenPort()); + +AddressRewriter rewriter = new AddressRewriter(backendToProxy); +p1.setRewriter(rewriter); +p2.setRewriter(rewriter); +p3.setRewriter(rewriter); +``` + +The driver now lives in a consistent alternate topology consisting entirely of proxies — every +connection it ever opens, including discovery-triggered ones, flows through a fault gate. +The map keys must be the **exact** `host:port` strings the server reports (watch out for +hostname vs. IP mismatches). `DriverFailoverProxyTest.assertOnlyConnectedThroughProxies` shows +how to verify no traffic leaks around the proxies. + +To drive the *real* replica set while the driver only sees proxies (e.g. trigger a genuine +`replSetStepDown`, poll `replSetGetStatus`), use `ControlChannel` — an auth-aware direct +connection to the real nodes, deliberately separate from the proxied data path. + +## Monitoring: `FrameObserver` + +```java +proxy.addObserver((dir, msg, ctx) -> + log.info("[{}] {} -> {}", ctx.listenPort(), dir, msg.getClass().getSimpleName())); +``` + +Observers are read-only by contract — they must not mutate the frame (rewriting is +`ResponseRewriter`'s job; the interfaces are deliberately separate: *fault = state, +rewrite = strategy, observe = listener*). `Slf4jFrameObserver` is a ready-made logging +implementation. Observer exceptions never kill a proxy thread. + +Today only `BACKEND_TO_CLIENT` frames fire: client→backend traffic is forwarded as raw, +length-prefixed bytes without parsing — deliberate pass-through fidelity, the proxy cannot +distort what it does not interpret. The `CLIENT_TO_BACKEND` direction exists in the enum and is +reserved for a consumer that actually needs it. + +## Injecting invalid or manipulated replies + +`ResponseRewriter` receives every parsed server reply and returns what the client should see — +including something intentionally broken: + +```java +proxy.setRewriter(reply -> { + if (reply instanceof OpMsg msg && msg.getFirstDoc() != null + && msg.getFirstDoc().containsKey("cursor")) { + msg.getFirstDoc().put("ok", 0.0); // flip a find reply into an error + msg.getFirstDoc().put("errmsg", "injected"); // ... or corrupt it any way you like + } + return reply; +}); +``` + +This is the hook for robustness tests: truncated cursors, unexpected error codes, protocol +violations, replies claiming a different topology than reality. Only the backend→client +direction can be rewritten — requests pass through untouched by design. + +## Limitations (honest list) + +- No latency injection — a frame is forwarded immediately or not at all. If you need slow-link + simulation, that would be a new `FaultMode`. +- No request (client→backend) rewriting or observation — see above, deliberate. +- `freeze` is not reversible per connection (matches reality, but don't expect a parked + connection to resume). +- It is test infrastructure: no TLS termination, no config file, one backend per proxy + instance. + +## Reference consumer + +`DriverFailoverProxyTest` (tag `wire-failover`) is the full-scale example: three proxies in +front of a real replica set, address rewriting, freeze/reset/close scenarios, stepdown via +`ControlChannel`, and read/write/messaging recovery assertions. It runs in the normal test +matrix against both MongoDB and PoppyDB — see the +[Developer Testing Guide](developer-testing-guide.md) for how the tags fit together. diff --git a/logs/release-6.1.6-20260128-092754.log b/logs/release-6.1.6-20260128-092754.log deleted file mode 100644 index 09ab4f12b..000000000 --- a/logs/release-6.1.6-20260128-092754.log +++ /dev/null @@ -1,5789 +0,0 @@ -[INFO] Scanning for projects... -Downloading from central: https://repo.maven.apache.org/maven2/org/codehaus/mojo/maven-metadata.xml -Downloading from central: https://repo.maven.apache.org/maven2/org/apache/maven/plugins/maven-metadata.xml -Progress (1): 4.6 kB Progress (1): 9.8 kB Progress (2): 9.8 kB | 3.7 kB Progress (2): 14 kB | 3.7 kB Progress (2): 14 kB | 7.5 kB Progress (2): 14 kB | 13 kB Progress (2): 14 kB | 19 kB Progress (2): 14 kB | 20 kB Downloaded from central: https://repo.maven.apache.org/maven2/org/codehaus/mojo/maven-metadata.xml (20 kB at 84 kB/s) -Downloaded from central: https://repo.maven.apache.org/maven2/org/apache/maven/plugins/maven-metadata.xml (14 kB at 59 kB/s) -[INFO] -[INFO] -------------------------< de.caluga:morphium >------------------------- -[INFO] Building Morphium 6.1.6-SNAPSHOT -[INFO] from pom.xml -[INFO] --------------------------------[ jar ]--------------------------------- -[INFO] -[INFO] --- release:2.5.3:clean (default-cli) @ morphium --- -[INFO] Cleaning up after release... -[INFO] -[INFO] --- release:2.5.3:prepare (default-cli) @ morphium --- -[INFO] Verifying that there are no local modifications... -[INFO] ignoring changes on: **/pom.xml.releaseBackup, **/pom.xml.next, **/pom.xml.tag, **/pom.xml.branch, **/release.properties, **/pom.xml.backup -[INFO] Executing: /bin/sh -c cd /Users/stephan/develop/morphium && git rev-parse --show-toplevel -[INFO] Working directory: /Users/stephan/develop/morphium -[INFO] Executing: /bin/sh -c cd /Users/stephan/develop/morphium && git status --porcelain . -[INFO] Working directory: /Users/stephan/develop/morphium -[WARNING] Ignoring unrecognized line: ?? logs/ -[INFO] Checking dependencies and plugins for snapshots ... -What is the release version for "Morphium"? (de.caluga:morphium) 6.1.6: : What is SCM release tag or label for "Morphium"? (de.caluga:morphium) v6.1.6: : What is the new development version for "Morphium"? (de.caluga:morphium) 6.1.7-SNAPSHOT: : [INFO] Transforming 'Morphium'... -[INFO] Not generating release POMs -[INFO] Executing goals 'clean verify'... -[WARNING] Maven will be executed in interactive mode, but no input stream has been configured for this MavenInvoker instance. -[INFO] [INFO] Scanning for projects... -[INFO] [INFO] -[INFO] [INFO] -------------------------< de.caluga:morphium >------------------------- -[INFO] [INFO] Building Morphium 6.1.6 -[INFO] [INFO] from pom.xml -[INFO] [INFO] --------------------------------[ jar ]--------------------------------- -[INFO] [INFO] -[INFO] [INFO] --- clean:3.2.0:clean (default-clean) @ morphium --- -[INFO] [INFO] Deleting /Users/stephan/develop/morphium/target -[INFO] [INFO] -[INFO] [INFO] --- resources:3.3.1:resources (default-resources) @ morphium --- -[INFO] [INFO] Copying 1 resource from src/main/resources to target/classes -[INFO] [INFO] -[INFO] [INFO] --- compiler:3.12.1:compile (default-compile) @ morphium --- -[INFO] [INFO] Recompiling the module because of changed source code. -[INFO] [INFO] Compiling 315 source files with javac [debug release 21] to target/classes -[INFO] [WARNING] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/ObjectMapperImpl.java:[22,19] sun.reflect.ReflectionFactory ist eine interne proprietäre API, die in einem zukünftigen Release entfernt werden kann -[INFO] [WARNING] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/aggregation/Expr.java:[91,62] Nicht-varargs-Aufruf von varargs-Methode mit ungenauem Argumenttyp für den letzten Parameter. -[INFO] Führen Sie für einen varargs-Aufruf eine Umwandlung mit Cast in java.lang.Object aus -[INFO] Führen Sie für einen Nicht-varargs-Aufruf eine Umwandlung mit Cast in java.lang.Object[] aus, um diese Warnung zu unterdrücken -[INFO] [WARNING] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:[281,65] Nicht-varargs-Aufruf von varargs-Methode mit ungenauem Argumenttyp für den letzten Parameter. -[INFO] Führen Sie für einen varargs-Aufruf eine Umwandlung mit Cast in java.lang.Class aus -[INFO] Führen Sie für einen Nicht-varargs-Aufruf eine Umwandlung mit Cast in java.lang.Class[] aus, um diese Warnung zu unterdrücken -[INFO] [WARNING] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/objectmapping/ByteMapper.java:[13,16] Byte(byte) in java.lang.Byte ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/ObjectMapperImpl.java:[54,19] sun.reflect.ReflectionFactory ist eine interne proprietäre API, die in einem zukünftigen Release entfernt werden kann -[INFO] [WARNING] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/ObjectMapperImpl.java:[54,50] sun.reflect.ReflectionFactory ist eine interne proprietäre API, die in einem zukünftigen Release entfernt werden kann -[INFO] [INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java: Einige Eingabedateien verwenden oder überschreiben eine veraltete API. -[INFO] [INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java: Wiederholen Sie die Kompilierung mit -Xlint:deprecation, um Details zu erhalten. -[INFO] [INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java: Einige Eingabedateien verwenden nicht geprüfte oder unsichere Vorgänge. -[INFO] [INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java: Wiederholen Sie die Kompilierung mit -Xlint:unchecked, um Details zu erhalten. -[INFO] [INFO] -[INFO] [INFO] --- resources:3.3.1:testResources (default-testResources) @ morphium --- -[INFO] [INFO] Not copying test resources -[INFO] [INFO] -[INFO] [INFO] --- compiler:3.12.1:testCompile (default-testCompile) @ morphium --- -[INFO] [INFO] Recompiling the module because of changed dependency. -[INFO] [INFO] Compiling 226 source files with javac [debug release 21] to target/test-classes -[INFO] [WARNING] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/mongo/suite/base/ListTests.java:[292,21] Integer(int) in java.lang.Integer ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/mongo/suite/base/CustomMapperTest.java:[60,25] Character(char) in java.lang.Character ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/mongo/suite/base/CustomMapperTest.java:[61,25] Long(long) in java.lang.Long ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/mongo/suite/base/CustomMapperTest.java:[62,28] Integer(int) in java.lang.Integer ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/mongo/suite/base/CustomMapperTest.java:[63,26] Float(double) in java.lang.Float ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/mongo/suite/base/CustomMapperTest.java:[64,27] Double(double) in java.lang.Double ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/mongo/suite/base/CustomMapperTest.java:[66,28] Boolean(boolean) in java.lang.Boolean ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/mongo/suite/base/CustomMapperTest.java:[67,25] Byte(byte) in java.lang.Byte ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/mongo/suite/base/CustomMapperTest.java:[68,26] Short(short) in java.lang.Short ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/mongo/suite/base/ChangeStreamTest.java:[458,23] Integer(int) in java.lang.Integer ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/morphium/driver/SingleMongoConnectionTest.java:[126,25] Character(char) in java.lang.Character ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/morphium/driver/SingleMongoConnectionTest.java:[127,25] Long(long) in java.lang.Long ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/morphium/driver/SingleMongoConnectionTest.java:[128,28] Integer(int) in java.lang.Integer ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/morphium/driver/SingleMongoConnectionTest.java:[129,26] Float(double) in java.lang.Float ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/morphium/driver/SingleMongoConnectionTest.java:[130,27] Double(double) in java.lang.Double ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/morphium/driver/SingleMongoConnectionTest.java:[132,28] Boolean(boolean) in java.lang.Boolean ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/morphium/driver/SingleMongoConnectionTest.java:[133,25] Byte(byte) in java.lang.Byte ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/morphium/driver/SingleMongoConnectionTest.java:[134,26] Short(short) in java.lang.Short ist veraltet und wurde zum Entfernen markiert -[INFO] [INFO] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/mongo/suite/base/BasicAdminTests.java: Einige Eingabedateien verwenden oder überschreiben eine veraltete API. -[INFO] [INFO] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/mongo/suite/base/BasicAdminTests.java: Wiederholen Sie die Kompilierung mit -Xlint:deprecation, um Details zu erhalten. -[INFO] [INFO] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/mongo/suite/inmem/MorphiumInMemTestBase.java: Einige Eingabedateien verwenden nicht geprüfte oder unsichere Vorgänge. -[INFO] [INFO] /Users/stephan/develop/morphium/src/test/java/de/caluga/test/mongo/suite/inmem/MorphiumInMemTestBase.java: Wiederholen Sie die Kompilierung mit -Xlint:unchecked, um Details zu erhalten. -[INFO] [INFO] -[INFO] [INFO] --- surefire:3.0.0:test (default-test) @ morphium --- -[INFO] [INFO] Tests are skipped. -[INFO] [INFO] -[INFO] [INFO] --- jar:3.2.2:jar (default-jar) @ morphium --- -[INFO] [INFO] Building jar: /Users/stephan/develop/morphium/target/morphium-6.1.6.jar -[INFO] [INFO] -[INFO] [INFO] >>> source:3.1.0:jar (attach-sources) > generate-sources @ morphium >>> -[INFO] [INFO] -[INFO] [INFO] <<< source:3.1.0:jar (attach-sources) < generate-sources @ morphium <<< -[INFO] [INFO] -[INFO] [INFO] -[INFO] [INFO] --- source:3.1.0:jar (attach-sources) @ morphium --- -[INFO] [INFO] Building jar: /Users/stephan/develop/morphium/target/morphium-6.1.6-sources.jar -[INFO] [INFO] -[INFO] [INFO] --- javadoc:3.4.1:jar (attach-javadocs) @ morphium --- -[INFO] [INFO] No previous run data found, generating javadoc. -[INFO] [ERROR] MavenReportException: Error while generating Javadoc: -[INFO] Exit code: 1 - Quelldateien werden geladen für Package de.caluga.morphium.aggregation... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.encryption... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.bulk... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.cache... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.cache.jcache... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.config... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.bson... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.bulk... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.wire... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.constants... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.mongodb... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.commands... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.commands.result... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.commands.auth... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.wireprotocol... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.inmem... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.async... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.objectmapping... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.server... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.server.netty... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.server.election... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.server.messaging... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.annotations.encryption... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.annotations... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.annotations.lifecycle... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.annotations.caching... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.writer... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.replicaset... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.query... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.query.geospatial... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.messaging... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.messaging.jms... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.validation... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.changestream... -[INFO] Javadoc-Informationen werden erstellt... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/ObjectMapperImpl.java:22: Warnung: ReflectionFactory ist eine interne proprietäre API, die in einem zukünftigen Release entfernt werden kann -[INFO] import sun.reflect.ReflectionFactory; -[INFO] ^ -[INFO] Index für alle Packages und Klassen wird erstellt... -[INFO] Standard-Doclet-Version 21.0.9+10-LTS -[INFO] Baum für alle Packages und Klassen wird erstellt... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/aggregation/AggregationIterator.java:20: Warnung: kein @param für -[INFO] public class AggregationIterator implements MorphiumAggregationIterator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/aggregation/AggregationIterator.java:20: Warnung: kein @param für -[INFO] public class AggregationIterator implements MorphiumAggregationIterator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/aggregation/Group.java:19: Warnung: kein @param für -[INFO] public class Group { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/aggregation/Group.java:19: Warnung: kein @param für -[INFO] public class Group { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/aggregation/Aggregator.java:31: Warnung: kein @param für -[INFO] public interface Aggregator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/aggregation/Aggregator.java:31: Warnung: kein @param für -[INFO] public interface Aggregator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/aggregation/AggregatorImpl.java:28: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/aggregation/AggregatorImpl.java:31: Warnung: kein @param für -[INFO] public class AggregatorImpl implements Aggregator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/aggregation/AggregatorImpl.java:31: Warnung: kein @param für -[INFO] public class AggregatorImpl implements Aggregator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumStorageAdapter.java:15: Warnung: kein @param für -[INFO] public abstract class MorphiumStorageAdapter implements MorphiumStorageListener { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumStorageListener.java:13: Warnung: Keine Hauptbeschreibung -[INFO] * @author stephan -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumStorageListener.java:16: Warnung: kein @param für -[INFO] public interface MorphiumStorageListener { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/StatisticKeys.java:7: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/DAO.java:9: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/DAO.java:12: Warnung: kein @param für -[INFO] public abstract class DAO { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/ObjectMapperImpl.java:49: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/bulk/MorphiumBulkContext.java:24: Warnung: kein @param für -[INFO] public class MorphiumBulkContext { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:17: Warnung: kein @param für -[INFO] public abstract class AbstractCacheSynchronizer { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/CacheSyncVetoException.java:7: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/CacheSyncListener.java:7: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/MorphiumCacheJCacheImpl.java:28: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/MessagingCacheSyncAdapter.java:9: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/jcache/CacheEntry.java:10: Warnung: kein @param für -[INFO] public class CacheEntry { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/jcache/CacheImpl.java:26: Warnung: kein @param für -[INFO] public class CacheImpl implements Cache> { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/jcache/CacheImpl.java:26: Warnung: kein @param für -[INFO] public class CacheImpl implements Cache> { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/MorphiumDriverOperation.java:7: Warnung: kein @param für -[INFO] public interface MorphiumDriverOperation { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/wire/NetworkCallHelper.java:18: Warnung: kein @param für -[INFO] public class NetworkCallHelper { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/wire/DriverBase.java:25: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/wireprotocol/OpMsg.java:28: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/async/AsyncOperationCallback.java:14: Warnung: kein @param für -[INFO] public interface AsyncOperationCallback { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/objectmapping/MorphiumTypeMapper.java:10: Warnung: kein @param für -[INFO] public interface MorphiumTypeMapper { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/Index.java:15: Fehler: Überschrift in der falschen Reihenfolge verwendet:

, im Vergleich zur impliziten vorhergehenden Überschrift:

-[INFO] *

Single-Field Indexes

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/UseIfnull.java:13: Warnung: Keine Hauptbeschreibung -[INFO] * @Deprecated This annotation is deprecated. The default behavior has been changed to accept null values -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/UseIfnull.java:13: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated This annotation is deprecated. The default behavior has been changed to accept null values -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/UseIfnull.java:17: Fehler: Überschrift in der falschen Reihenfolge verwendet:

, im Vergleich zur impliziten vorhergehenden Überschrift:

-[INFO] *

Migration Guide:

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/IgnoreNullFromDB.java:15: Fehler: Überschrift in der falschen Reihenfolge verwendet:

, im Vergleich zur impliziten vorhergehenden Überschrift:

-[INFO] *

Behavior Summary:

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/CreationTime.java:16: Warnung: leeres -Tag -[INFO] * -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/CreationTime.java:16: Fehler: Element nicht geschlossen: code -[INFO] * -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/CreationTime.java:18: Fehler: unbekanntes Tag: CreationTime -[INFO] * @CreationTime class Test { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/CreationTime.java:19: Fehler: unbekanntes Tag: CreationTime -[INFO] * @CreationTime private long theTimestamp; -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/CreationTime.java:22: Fehler: unerwartetes Endtag: -[INFO] * -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/WriteSafety.java:16: Fehler: ungültiges Endtag:
-[INFO] *
-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/Aliases.java:15: Fehler: Element nicht geschlossen: code -[INFO] * -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/Aliases.java:18: Fehler: unbekanntes Tag: Aliases -[INFO] * @Aliases("alias","hugo") private String value; -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/Aliases.java:21: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/Aliases.java:22: Fehler: unerwartetes Endtag: -[INFO] * -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/Entity.java:16: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/writer/WriterTask.java:15: Warnung: kein @param für -[INFO] public interface WriterTask extends Runnable { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/writer/AsyncWriterImpl.java:15: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/query/QueryIterator.java:23: Warnung: kein @param für -[INFO] public class QueryIterator implements MorphiumIterator, Iterator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/query/MongoFieldImpl.java:29: Warnung: kein @param für -[INFO] public class MongoFieldImpl implements MongoField { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/query/MorphiumIterator.java:24: Warnung: kein @param für -[INFO] public interface MorphiumIterator extends Iterable, Iterator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/query/Query.java:61: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/query/Query.java:64: Warnung: kein @param für -[INFO] public class Query implements Cloneable { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/query/MongoField.java:22: Warnung: kein @param für -[INFO] public interface MongoField { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/MessageListener.java:7: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/MessageListener.java:9: Warnung: kein @param für -[INFO] public interface MessageListener { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/Msg.java:19: Fehler: ungültiges Endtag:
-[INFO] *
-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/AbortTransactionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java:67: Warnung: keine Beschreibung für @return -[INFO] * @return -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:9: Warnung: kein Kommentar -[INFO] public class AbortTransactionCommand extends AdminMongoCommand{ -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:14: Warnung: kein Kommentar -[INFO] public AbortTransactionCommand(MongoConnection d) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java:185: Warnung: kein Kommentar -[INFO] public Map asMap() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/SingleResultCommand.java:10: Warnung: kein Kommentar -[INFO] Map asMap(); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java:270: Warnung: kein Kommentar -[INFO] public abstract String getCommandName(); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:39: Warnung: kein Kommentar -[INFO] public UUID getLsid() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:48: Warnung: kein Kommentar -[INFO] public long getTxnNumber() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:30: Warnung: kein Kommentar -[INFO] public boolean isAutocommit() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:34: Warnung: kein Kommentar -[INFO] public AbortTransactionCommand setAutocommit(boolean autocommit) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:43: Warnung: kein Kommentar -[INFO] public AbortTransactionCommand setLsid(UUID lsid) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:52: Warnung: kein Kommentar -[INFO] public AbortTransactionCommand setTxnNumber(long txnNumber) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/AbstractCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:21: Warnung: kein Kommentar -[INFO] protected final Hashtable, Vector> listenerForType = new Hashtable<>(); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:20: Warnung: kein Kommentar -[INFO] protected final List listeners = Collections.synchronizedList(new ArrayList<>()); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:18: Warnung: kein Kommentar -[INFO] protected static final Logger log = LoggerFactory.getLogger(MessagingCacheSynchronizer.class); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:19: Warnung: kein Kommentar -[INFO] protected final Morphium morphium; -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:24: Warnung: kein Kommentar -[INFO] public AbstractCacheSynchronizer(Morphium morphium) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:37: Warnung: kein Kommentar -[INFO] public void addSyncListener(Class type, T cl) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:28: Warnung: kein Kommentar -[INFO] public void addSyncListener(T cl) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:66: Warnung: kein Kommentar -[INFO] public void firePostClearEvent(Class type) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:51: Warnung: kein Kommentar -[INFO] protected void firePreClearEvent(Class type) throws CacheSyncVetoException { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:43: Warnung: kein Kommentar -[INFO] public void removeSyncListener(Class type, T cl) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:32: Warnung: kein Kommentar -[INFO] public void removeSyncListener(T cl) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/AdditionalData.html wird generiert... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/AdditionalData.java:19: Warnung: kein Kommentar -[INFO] boolean readOnly() default true; -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/AdminMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AdminMongoCommand.java:9: Warnung: kein Kommentar -[INFO] public abstract class AdminMongoCommand extends MongoCommand implements SingleResultCommand { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AdminMongoCommand.java:10: Warnung: kein Kommentar -[INFO] public AdminMongoCommand(MongoConnection d) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/SingleResultCommand.java:8: Warnung: kein Kommentar -[INFO] Map execute() throws MorphiumDriverException; -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java:272: Warnung: kein Kommentar -[INFO] public int executeAsync() throws MorphiumDriverException { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/AESEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/encryption/AESEncryptionProvider.java:7: Warnung: kein Kommentar -[INFO] public class AESEncryptionProvider implements ValueEncryptionProvider { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/encryption/AESEncryptionProvider.java:11: Warnung: kein Kommentar -[INFO] public AESEncryptionProvider() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:14: Warnung: kein Kommentar -[INFO] byte[] decrypt(byte[] input); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:12: Warnung: kein Kommentar -[INFO] byte[] encrypt(byte[] input); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:10: Warnung: kein Kommentar -[INFO] void sedDecryptionKeyBase64(String key); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:8: Warnung: kein Kommentar -[INFO] void setDecryptionKey(byte[] key); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:4: Warnung: kein Kommentar -[INFO] void setEncryptionKey(byte[] key); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:6: Warnung: kein Kommentar -[INFO] void setEncryptionKeyBase64(String key); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/AggregateMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:15: Warnung: kein Kommentar -[INFO] public class AggregateMongoCommand extends ReadMongoCommand implements MultiResultCommand { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:29: Warnung: kein Kommentar -[INFO] public AggregateMongoCommand(MongoConnection d) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/MultiResultCommand.java:15: Warnung: kein Kommentar -[INFO] Map asMap(); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:160: Warnung: kein Kommentar -[INFO] public Map explain(ExplainVerbosity verbosity) throws MorphiumDriverException{ -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java:108: Warnung: kein Kommentar -[INFO] public T fromMap(Map m) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:60: Warnung: kein Kommentar -[INFO] public Boolean getAllowDiskUse() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:33: Warnung: kein Kommentar -[INFO] public Integer getBatchSize() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:78: Warnung: kein Kommentar -[INFO] public Boolean getBypassDocumentValidation() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:96: Warnung: kein Kommentar -[INFO] public Map getCollation() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:132: Warnung: kein Kommentar -[INFO] public Map getCursor() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:51: Warnung: kein Kommentar -[INFO] public Boolean getExplain() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:105: Warnung: kein Kommentar -[INFO] public Object getHint() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:123: Warnung: kein Kommentar -[INFO] public Map getLet() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:69: Warnung: kein Kommentar -[INFO] public Integer getMaxWaitTime() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:42: Warnung: kein Kommentar -[INFO] public List> getPipeline() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:87: Warnung: kein Kommentar -[INFO] public Map getReadConcern() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:114: Warnung: kein Kommentar -[INFO] public Map getWriteConern() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:64: Warnung: kein Kommentar -[INFO] public AggregateMongoCommand setAllowDiskUse(Boolean allowDiskUse) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/AggregationIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/Aggregator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/Aggregator.BucketGranularity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/Aggregator.GeoNearFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/Aggregator.MergeActionWhenMatched.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/Aggregator.MergeActionWhenNotMatched.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/AggregatorImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Aliases.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/AnnotationAndReflectionHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/AppendEntriesRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/AppendEntriesResponse.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/async/AsyncCallbackAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/async/AsyncOperationCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/async/AsyncOperationType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/AsyncWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/AsyncWrites.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/AtomicBooleanMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/AtomicDecimal.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/AtomicIntegerMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/AtomicLongMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/AuthSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/BigDecimalMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/BigIntegerTypeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/BinarySerializedObject.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/BsonDecoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/BsonEncoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/BsonGeoMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/BufferedMorphiumWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/BulkContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/BulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/BulkRequestContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/ByteMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/Cache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/Cache.ClearStrategy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/Cache.SyncCacheStrategy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/CacheEntry.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/CacheEventVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/CacheHousekeeper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/CacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/CacheImpl.CEvent.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/CacheListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/CacheManagerImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/CacheSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/CacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/CacheSyncVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/CachingProviderImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Capped.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/changestream/ChangeStreamEvent.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/changestream/ChangeStreamListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/changestream/ChangeStreamMonitor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/CharacterMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/ClearCollectionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/ClusterSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Collation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Collation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Collation.Alternate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Collation.CaseFirst.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Collation.MaxVariable.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Collation.Strength.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/CollectionCheckSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/CollectionCheckSettings.CappedCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/CollectionCheckSettings.IndexCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/CollectionInfo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/CollStatsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/CommitTransactionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/ConfNode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/ConnectionSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/ConnectionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/Consumer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/Context.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/CountMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/CreateCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/CreateCommand.Granularity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/CreateIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/CreateRoleAdminCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/CreateRoleAdminCommand.Privilege.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/CreateRoleAdminCommand.Resource.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/CreateUserAdminCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/CreationTime.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/CurrentOpCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/CursorResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/DAO.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/DbStatsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/DefaultEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/DefaultNameProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/DefaultReadPreference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/DeleteBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/DeleteMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/DistinctMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/Doc.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Driver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/DriverBase.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/DriverSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/DriverTailableIterationCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/DropDatabaseMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/DropIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/DropMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/ElectionConfig.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/ElectionManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/ElectionNetworkClient.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/ElectionState.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Embedded.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/encryption/Encrypted.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/EncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/EncryptionSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Entity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Entity.ReadConcernLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Entity.ValidationAction.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Entity.ValidationLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/ExplainCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/ExplainCommand.ExplainVerbosity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/Expr.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/Expr.ValueExpr.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/FieldNames.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/FilterExpression.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/FindAndModifyMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/FindCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/FunctionNotSupportedException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/GenericCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/Geo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/GeoType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/GetMoreMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/Group.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/HelloCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/HelloResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/Host.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/HouseKeepingHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Id.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/IgnoreFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/IgnoreNullFromDB.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Index.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/IndexDescription.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/InMemAggregator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/InMemDumpContainer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/InMemoryDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/InMemoryDriver.MapReduceEmitter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/InMemTransactionContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/InsertBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/InsertMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/InstantMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/validation/JavaxValidationStorageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSBytesMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSConnectionConsumer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSConnectionFactory.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSDestination.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSMapMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSObjectMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSQueue.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSSession.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSTextMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSTopic.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/KillCursorsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/LastAccess.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/LastChange.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/LazyDeReferencingProxy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/Lifecycle.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/LimitToFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/LineString.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/ListCollectionsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/ListDatabasesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/ListIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/ListResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/LocalDateMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/LocalDateTimeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/LocalTimeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/MapReduceCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/mongodb/Maximums.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/MessageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/MessageRejectedException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/MessageRejectedException.RejectionHandler.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Messaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/MessagingCacheSyncAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/MessagingCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/MessagingCacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/messaging/MessagingCollectionInfo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/messaging/MessagingOptimizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/MessagingRegistry.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/MessagingSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/MessagingSettings.RecipientCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/MessagingSettings.TopicCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/MongoBob.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/MongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/MongoCommandHandler.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/MongoConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/MongoConnectionThread.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/MongoEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/MongoField.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/MongoFieldImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/MongoJSScript.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/MongoMaxKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/MongoMinKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/MongoTimestamp.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/MongoType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/MongoWireProtocolDecoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/MongoWireProtocolEncoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Morphium.html wird generiert... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:794: Fehler: Nicht abgeschlossenes Inlinetag -[INFO] * Please use {@link Morphium#setInEntity(Object, String, Map) -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1015: Fehler: Nicht wohlgeformte HTML -[INFO] * unmarshalled, you might get MongoMaps -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1140: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1153: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1164: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1243: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1272: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1283: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1296: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1459: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1470: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1481: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1493: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/Morphium.java:810: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use {Morphium{@link #unsetInEntity(Object, String, String, AsyncOperationCallback)} instead. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/Morphium.java:1073: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/Morphium.java:1094: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/Morphium.java:1169: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use {@link Morphium#remove(List, String)} -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/Morphium.java:1178: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use {@link Morphium#remove(List, String, AsyncOperationCallback)} -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/Morphium.java:1672: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated - for read access use {@link Query} instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/MorphiumAccessVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/MorphiumAggregationIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/MorphiumBase.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/bulk/MorphiumBulkContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/MorphiumCache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/MorphiumCacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/MorphiumCacheJCacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumCollection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/MorphiumConfig.html wird generiert... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:940: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:949: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:957: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:966: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:975: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:984: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:993: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:1002: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:1010: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:1018: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:1026: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/MorphiumConfig.CompressionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/MorphiumConfigResolver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumCursorAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumDriver.CompressionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumDriver.DriverStatsKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumDriverException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumDriverNetworkException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumDriverOperation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumId.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/MorphiumIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/MorphiumMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/MorphiumObjectMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/MorphiumReference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/MorphiumServer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/MorphiumServerCLI.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/MorphiumStorageAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/MorphiumStorageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/MorphiumStorageListener.UpdateTypes.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumTransactionContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/MorphiumTransactionContextImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/MorphiumTypeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/MorphiumWriter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/MorphiumWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/MorphiumWriterImpl.WT.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/Msg.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/Msg.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/MsgLock.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/MsgLock.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/MultiCollectionMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/MultiCollectionMessaging.java:1786: Fehler: @param-Name nicht gefunden -[INFO] * @param timoutInMs milliseconds to wait until listener is removed -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/MultiLineString.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/MultiPoint.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/MultiPolygon.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/MultiResultCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/NameProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/NetworkCallHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/NetworkCallHelper.ErrorCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/NoCache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/ObjectMapperImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/ObjectMappingSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpCompressed.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpDelete.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpGetMore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpInsert.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpKillCursors.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/OplogListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpMsg.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpQuery.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpReply.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/Point.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/Polygon.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/PooledDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/PooledDriver.ConnectionContainer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/PooledDriver.PingStats.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/PostLoad.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/PostRemove.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/PostStore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/PostUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/PreRemove.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/PreStore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/PreUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/Producer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Property.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/Property.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/PropertyEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/Query.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/Query.TextSearchLanguages.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/QueryHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/QueryIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/ReadAccessType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/ReadMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/ReadOnly.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/ReadPreference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/ReadPreferenceLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/ReadPreferenceType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Reference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/RemoveProcessTask.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/RenameCollectionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/ReplicaSetConf.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/ReplicaSetNode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/ReplicaSetStatus.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/ReplicasetStatusListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/ReplicastStatusCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/ReplicationCoordinator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/ReplicationManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/RSAEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/RunCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/RunCommand.Command.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/RunCommand.ErrorCode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/RunCommand.Response.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/RunCommandResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/SafetyLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/SaslAuthCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Sequence.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Sequence.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Sequence.SeqLock.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/SequenceGenerator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/Settings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/ShortMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/ShutdownCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/ShutdownListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/SingleBatchCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/SingleCollectionMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:128: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:139: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:147: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:155: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:172: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:189: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:202: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - processMultiple is unused -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:1835: Fehler: @param-Name nicht gefunden -[INFO] * @param timoutInMs - milliseconds to wait until listener is removed -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/SingleCollectionMessaging.AsyncMessageCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/SingleCollectionMessaging.MessageTimeoutException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/SingleCollectionMessaging.ProcessingQueueElement.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/SingleCollectionMessaging.SystemShutdownException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/SingleElementCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/SingleElementResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/SingleMongoConnectDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/SingleMongoConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/SingleMongoConnectionCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/SingleResultCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/SslHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/StatisticKeys.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Statistics.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/StatisticValue.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/StatusInfoListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/StatusInfoListener.InternalCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/StatusInfoListener.StatusInfoLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/StepDownCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/StoreMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/ThreadPoolSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/ThrowOnError.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/TimestampMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Transient.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/UpdateBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/UpdateMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/UseIfnull.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Utils.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/UtilsMap.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/UUIDRepresentation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/ValueEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/VoteRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/VoteResponse.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/WatchCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/WatchCommand.FullDocumentBeforeChangeEnum.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/WatchCommand.FullDocumentEnum.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/WatchCursorManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/WatchingCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/WatchingCacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/WireProtocolMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/WireProtocolMessage.OpCode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/WriteAccessType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/WriteBuffer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/WriteBuffer.STRATEGY.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/WriteConcern.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/WriteMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/WriterSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/WriterTask.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/WriteSafety.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/encryption/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/encryption/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/async/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/async/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/bulk/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/bulk/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/changestream/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/changestream/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/mongodb/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/mongodb/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/messaging/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/messaging/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/validation/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/validation/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/constant-values.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/serialized-form.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/AggregationIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/Group.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/Aggregator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/Aggregator.MergeActionWhenNotMatched.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/Aggregator.MergeActionWhenMatched.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/Aggregator.BucketGranularity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/Aggregator.GeoNearFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/AggregatorImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/MorphiumAggregationIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/Expr.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/Expr.ValueExpr.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/class-use/AESEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/class-use/RSAEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/class-use/DefaultEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/class-use/EncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/class-use/MongoEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/class-use/ValueEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/class-use/PropertyEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/MorphiumStorageAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Sequence.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Sequence.SeqLock.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Sequence.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/FilterExpression.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/MorphiumStorageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/MorphiumStorageListener.UpdateTypes.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/LazyDeReferencingProxy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/StatisticKeys.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Utils.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/DefaultNameProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/MorphiumBase.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/MorphiumAccessVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/CollectionInfo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/WriteAccessType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Statistics.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/MongoType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/AnnotationAndReflectionHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/DAO.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Collation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Collation.Strength.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Collation.MaxVariable.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Collation.CaseFirst.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Collation.Alternate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/UtilsMap.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/SequenceGenerator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/IndexDescription.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/NameProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/MorphiumReference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/StatisticValue.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/ReadAccessType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/MorphiumConfig.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/MorphiumConfig.CompressionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/ShutdownListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Morphium.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/ObjectMapperImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/MorphiumConfigResolver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/ThrowOnError.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/BinarySerializedObject.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/bulk/class-use/MorphiumBulkContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/WatchingCacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/WatchingCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/MessagingCacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/MessagingCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/CacheSyncVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/MorphiumCacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/AbstractCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/CacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/CacheHousekeeper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/MorphiumCache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/MorphiumCacheJCacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/MessagingCacheSyncAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/CacheListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CacheEventVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CacheManagerImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CacheEntry.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/class-use/HouseKeepingHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CacheImpl.CEvent.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CachingProviderImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/EncryptionSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/MessagingSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/MessagingSettings.RecipientCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/MessagingSettings.TopicCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/DriverSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/AuthSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/ConnectionSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/ClusterSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/ObjectMappingSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/CollectionCheckSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/CollectionCheckSettings.CappedCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/CollectionCheckSettings.IndexCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/ThreadPoolSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/Settings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/CacheSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/WriterSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/class-use/MongoBob.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/class-use/MongoJSScript.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/class-use/MongoMaxKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/class-use/UUIDRepresentation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/class-use/BsonDecoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/class-use/MongoTimestamp.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/class-use/MongoMinKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/class-use/BsonEncoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumCollection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/FunctionNotSupportedException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriver.CompressionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriver.DriverStatsKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/Doc.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriverNetworkException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/SingleElementCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriverException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumId.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/DriverTailableIterationCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/SingleBatchCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/ReadPreference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/ReadPreferenceType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/WriteConcern.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumCursorAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumTransactionContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriverOperation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/class-use/InsertBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/class-use/BulkRequestContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/class-use/UpdateBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/class-use/DeleteBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/class-use/BulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/SslHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/HelloResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/NetworkCallHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/NetworkCallHelper.ErrorCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/BulkContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/MorphiumTransactionContextImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/ConnectionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/SingleMongoConnectDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/PooledDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/PooledDriver.ConnectionContainer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/PooledDriver.PingStats.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/AtomicDecimal.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/DriverBase.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/MongoConnectionThread.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/SingleMongoConnectionCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/Host.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/MongoConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/SingleMongoConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/class-use/RunCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/class-use/RunCommand.ErrorCode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/class-use/RunCommand.Command.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/class-use/RunCommand.Response.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/mongodb/class-use/Maximums.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/ReadMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/CreateCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/CreateCommand.Granularity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/InsertMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/GenericCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/DropIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/FindAndModifyMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/MongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/HelloCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/ExplainCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/ExplainCommand.ExplainVerbosity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/GetMoreMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/DistinctMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/StepDownCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/CollStatsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/DbStatsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/KillCursorsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/AggregateMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/DropDatabaseMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/MapReduceCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/ListDatabasesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/CreateIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/UpdateMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/CommitTransactionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/SingleResultCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/AbortTransactionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/WatchCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/WatchCommand.FullDocumentEnum.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/WatchCommand.FullDocumentBeforeChangeEnum.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/ClearCollectionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/DropMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/ShutdownCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/MultiResultCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/RenameCollectionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/WriteMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/FindCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/CountMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/StoreMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/CurrentOpCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/AdminMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/ListCollectionsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/ListIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/ReplicastStatusCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/DeleteMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/class-use/RunCommandResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/class-use/SingleElementResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/class-use/CursorResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/class-use/ListResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/class-use/SaslAuthCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/class-use/CreateUserAdminCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/class-use/CreateRoleAdminCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/class-use/CreateRoleAdminCommand.Resource.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/class-use/CreateRoleAdminCommand.Privilege.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpMsg.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/WireProtocolMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/WireProtocolMessage.OpCode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpGetMore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpQuery.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpDelete.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpInsert.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpCompressed.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpReply.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpKillCursors.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/class-use/InMemTransactionContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/class-use/InMemoryDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/class-use/InMemoryDriver.MapReduceEmitter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/class-use/InMemDumpContainer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/class-use/InMemAggregator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/class-use/QueryHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/async/class-use/AsyncOperationType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/async/class-use/AsyncCallbackAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/async/class-use/AsyncOperationCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/ShortMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/MorphiumObjectMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/TimestampMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/AtomicBooleanMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/BigDecimalMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/BsonGeoMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/ByteMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/LocalTimeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/LocalDateMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/LocalDateTimeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/CharacterMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/BigIntegerTypeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/AtomicLongMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/AtomicIntegerMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/InstantMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/MorphiumTypeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/class-use/ReplicationManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/class-use/MorphiumServerCLI.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/class-use/ReplicationCoordinator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/class-use/MorphiumServer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/class-use/MongoWireProtocolEncoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/class-use/MongoCommandHandler.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/class-use/MongoWireProtocolDecoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/class-use/WatchCursorManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/class-use/ElectionNetworkClient.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/class-use/AppendEntriesResponse.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/class-use/VoteRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/class-use/ElectionConfig.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/class-use/AppendEntriesRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/class-use/ElectionState.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/class-use/ElectionManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/class-use/VoteResponse.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/messaging/class-use/MessagingOptimizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/messaging/class-use/MessagingCollectionInfo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/encryption/class-use/Encrypted.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/DefaultReadPreference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Index.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/UseIfnull.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Property.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Id.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Capped.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/SafetyLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/AdditionalData.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Driver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/LimitToFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Reference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/LastChange.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/ReadPreferenceLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Embedded.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Transient.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Collation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/IgnoreNullFromDB.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/CreationTime.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/WriteSafety.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Aliases.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/IgnoreFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Entity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Entity.ReadConcernLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Entity.ValidationLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Entity.ValidationAction.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Messaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/LastAccess.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/ReadOnly.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PostStore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PostRemove.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PostLoad.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PreStore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PostUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PreRemove.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/Lifecycle.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PreUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/class-use/WriteBuffer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/class-use/WriteBuffer.STRATEGY.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/class-use/Cache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/class-use/Cache.SyncCacheStrategy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/class-use/Cache.ClearStrategy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/class-use/NoCache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/class-use/AsyncWrites.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/class-use/MorphiumWriter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/class-use/WriterTask.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/class-use/AsyncWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/class-use/BufferedMorphiumWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/class-use/MorphiumWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/class-use/MorphiumWriterImpl.WT.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/class-use/OplogListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/class-use/ReplicasetStatusListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/class-use/ReplicaSetConf.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/class-use/ReplicaSetNode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/class-use/ConfNode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/class-use/ReplicaSetStatus.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/class-use/QueryIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/class-use/Property.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/class-use/MongoFieldImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/class-use/MorphiumIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/class-use/Query.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/class-use/Query.TextSearchLanguages.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/class-use/MongoField.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/class-use/FieldNames.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/class-use/Polygon.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/class-use/Geo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/class-use/MultiPoint.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/class-use/Point.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/class-use/MultiLineString.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/class-use/MultiPolygon.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/class-use/GeoType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/class-use/LineString.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/MessageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/Msg.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/Msg.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/MultiCollectionMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/MorphiumMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/MessagingRegistry.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/MsgLock.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/MsgLock.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/StatusInfoListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/StatusInfoListener.InternalCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/StatusInfoListener.StatusInfoLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/RemoveProcessTask.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/SingleCollectionMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/SingleCollectionMessaging.AsyncMessageCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/SingleCollectionMessaging.ProcessingQueueElement.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/SingleCollectionMessaging.SystemShutdownException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/SingleCollectionMessaging.MessageTimeoutException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/MessageRejectedException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/MessageRejectedException.RejectionHandler.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSTextMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSMapMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSObjectMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSDestination.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/Context.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSSession.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSConnectionFactory.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSTopic.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/Consumer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/Producer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSQueue.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSConnectionConsumer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSBytesMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/validation/class-use/JavaxValidationStorageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/changestream/class-use/ChangeStreamListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/changestream/class-use/ChangeStreamMonitor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/changestream/class-use/ChangeStreamEvent.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/encryption/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/async/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/bulk/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/changestream/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/mongodb/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/messaging/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/validation/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/overview-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/deprecated-list.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/index.html wird generiert... -[INFO] Index für alle Klassen wird erstellt... -[INFO] /Users/stephan/develop/morphium/target/apidocs/allclasses-index.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/allpackages-index.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/index-all.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/search.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/overview-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/help-doc.html wird generiert... -[INFO] 52 Fehler -[INFO] 100 Warnungen -[INFO] -[INFO] Command line was: /usr/bin/javadoc -J-Xmx2048m @options @packages -[INFO] -[INFO] Refer to the generated Javadoc files in '/Users/stephan/develop/morphium/target/apidocs' dir. -[INFO] -[INFO] org.apache.maven.reporting.MavenReportException: -[INFO] Exit code: 1 - Quelldateien werden geladen für Package de.caluga.morphium.aggregation... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.encryption... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.bulk... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.cache... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.cache.jcache... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.config... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.bson... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.bulk... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.wire... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.constants... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.mongodb... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.commands... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.commands.result... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.commands.auth... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.wireprotocol... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.inmem... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.async... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.objectmapping... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.server... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.server.netty... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.server.election... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.server.messaging... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.annotations.encryption... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.annotations... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.annotations.lifecycle... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.annotations.caching... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.writer... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.replicaset... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.query... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.query.geospatial... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.messaging... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.messaging.jms... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.validation... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.changestream... -[INFO] Javadoc-Informationen werden erstellt... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/ObjectMapperImpl.java:22: Warnung: ReflectionFactory ist eine interne proprietäre API, die in einem zukünftigen Release entfernt werden kann -[INFO] import sun.reflect.ReflectionFactory; -[INFO] ^ -[INFO] Index für alle Packages und Klassen wird erstellt... -[INFO] Standard-Doclet-Version 21.0.9+10-LTS -[INFO] Baum für alle Packages und Klassen wird erstellt... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/aggregation/AggregationIterator.java:20: Warnung: kein @param für -[INFO] public class AggregationIterator implements MorphiumAggregationIterator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/aggregation/AggregationIterator.java:20: Warnung: kein @param für -[INFO] public class AggregationIterator implements MorphiumAggregationIterator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/aggregation/Group.java:19: Warnung: kein @param für -[INFO] public class Group { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/aggregation/Group.java:19: Warnung: kein @param für -[INFO] public class Group { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/aggregation/Aggregator.java:31: Warnung: kein @param für -[INFO] public interface Aggregator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/aggregation/Aggregator.java:31: Warnung: kein @param für -[INFO] public interface Aggregator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/aggregation/AggregatorImpl.java:28: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/aggregation/AggregatorImpl.java:31: Warnung: kein @param für -[INFO] public class AggregatorImpl implements Aggregator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/aggregation/AggregatorImpl.java:31: Warnung: kein @param für -[INFO] public class AggregatorImpl implements Aggregator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumStorageAdapter.java:15: Warnung: kein @param für -[INFO] public abstract class MorphiumStorageAdapter implements MorphiumStorageListener { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumStorageListener.java:13: Warnung: Keine Hauptbeschreibung -[INFO] * @author stephan -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumStorageListener.java:16: Warnung: kein @param für -[INFO] public interface MorphiumStorageListener { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/StatisticKeys.java:7: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/DAO.java:9: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/DAO.java:12: Warnung: kein @param für -[INFO] public abstract class DAO { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/ObjectMapperImpl.java:49: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/bulk/MorphiumBulkContext.java:24: Warnung: kein @param für -[INFO] public class MorphiumBulkContext { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:17: Warnung: kein @param für -[INFO] public abstract class AbstractCacheSynchronizer { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/CacheSyncVetoException.java:7: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/CacheSyncListener.java:7: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/MorphiumCacheJCacheImpl.java:28: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/MessagingCacheSyncAdapter.java:9: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/jcache/CacheEntry.java:10: Warnung: kein @param für -[INFO] public class CacheEntry { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/jcache/CacheImpl.java:26: Warnung: kein @param für -[INFO] public class CacheImpl implements Cache> { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/jcache/CacheImpl.java:26: Warnung: kein @param für -[INFO] public class CacheImpl implements Cache> { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/MorphiumDriverOperation.java:7: Warnung: kein @param für -[INFO] public interface MorphiumDriverOperation { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/wire/NetworkCallHelper.java:18: Warnung: kein @param für -[INFO] public class NetworkCallHelper { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/wire/DriverBase.java:25: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/wireprotocol/OpMsg.java:28: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/async/AsyncOperationCallback.java:14: Warnung: kein @param für -[INFO] public interface AsyncOperationCallback { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/objectmapping/MorphiumTypeMapper.java:10: Warnung: kein @param für -[INFO] public interface MorphiumTypeMapper { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/Index.java:15: Fehler: Überschrift in der falschen Reihenfolge verwendet:

, im Vergleich zur impliziten vorhergehenden Überschrift:

-[INFO] *

Single-Field Indexes

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/UseIfnull.java:13: Warnung: Keine Hauptbeschreibung -[INFO] * @Deprecated This annotation is deprecated. The default behavior has been changed to accept null values -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/UseIfnull.java:13: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated This annotation is deprecated. The default behavior has been changed to accept null values -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/UseIfnull.java:17: Fehler: Überschrift in der falschen Reihenfolge verwendet:

, im Vergleich zur impliziten vorhergehenden Überschrift:

-[INFO] *

Migration Guide:

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/IgnoreNullFromDB.java:15: Fehler: Überschrift in der falschen Reihenfolge verwendet:

, im Vergleich zur impliziten vorhergehenden Überschrift:

-[INFO] *

Behavior Summary:

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/CreationTime.java:16: Warnung: leeres -Tag -[INFO] * -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/CreationTime.java:16: Fehler: Element nicht geschlossen: code -[INFO] * -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/CreationTime.java:18: Fehler: unbekanntes Tag: CreationTime -[INFO] * @CreationTime class Test { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/CreationTime.java:19: Fehler: unbekanntes Tag: CreationTime -[INFO] * @CreationTime private long theTimestamp; -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/CreationTime.java:22: Fehler: unerwartetes Endtag: -[INFO] * -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/WriteSafety.java:16: Fehler: ungültiges Endtag:
-[INFO] *
-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/Aliases.java:15: Fehler: Element nicht geschlossen: code -[INFO] * -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/Aliases.java:18: Fehler: unbekanntes Tag: Aliases -[INFO] * @Aliases("alias","hugo") private String value; -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/Aliases.java:21: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/Aliases.java:22: Fehler: unerwartetes Endtag: -[INFO] * -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/Entity.java:16: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/writer/WriterTask.java:15: Warnung: kein @param für -[INFO] public interface WriterTask extends Runnable { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/writer/AsyncWriterImpl.java:15: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/query/QueryIterator.java:23: Warnung: kein @param für -[INFO] public class QueryIterator implements MorphiumIterator, Iterator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/query/MongoFieldImpl.java:29: Warnung: kein @param für -[INFO] public class MongoFieldImpl implements MongoField { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/query/MorphiumIterator.java:24: Warnung: kein @param für -[INFO] public interface MorphiumIterator extends Iterable, Iterator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/query/Query.java:61: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/query/Query.java:64: Warnung: kein @param für -[INFO] public class Query implements Cloneable { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/query/MongoField.java:22: Warnung: kein @param für -[INFO] public interface MongoField { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/MessageListener.java:7: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/MessageListener.java:9: Warnung: kein @param für -[INFO] public interface MessageListener { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/Msg.java:19: Fehler: ungültiges Endtag:
-[INFO] *
-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/AbortTransactionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java:67: Warnung: keine Beschreibung für @return -[INFO] * @return -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:9: Warnung: kein Kommentar -[INFO] public class AbortTransactionCommand extends AdminMongoCommand{ -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:14: Warnung: kein Kommentar -[INFO] public AbortTransactionCommand(MongoConnection d) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java:185: Warnung: kein Kommentar -[INFO] public Map asMap() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/SingleResultCommand.java:10: Warnung: kein Kommentar -[INFO] Map asMap(); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java:270: Warnung: kein Kommentar -[INFO] public abstract String getCommandName(); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:39: Warnung: kein Kommentar -[INFO] public UUID getLsid() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:48: Warnung: kein Kommentar -[INFO] public long getTxnNumber() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:30: Warnung: kein Kommentar -[INFO] public boolean isAutocommit() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:34: Warnung: kein Kommentar -[INFO] public AbortTransactionCommand setAutocommit(boolean autocommit) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:43: Warnung: kein Kommentar -[INFO] public AbortTransactionCommand setLsid(UUID lsid) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:52: Warnung: kein Kommentar -[INFO] public AbortTransactionCommand setTxnNumber(long txnNumber) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/AbstractCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:21: Warnung: kein Kommentar -[INFO] protected final Hashtable, Vector> listenerForType = new Hashtable<>(); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:20: Warnung: kein Kommentar -[INFO] protected final List listeners = Collections.synchronizedList(new ArrayList<>()); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:18: Warnung: kein Kommentar -[INFO] protected static final Logger log = LoggerFactory.getLogger(MessagingCacheSynchronizer.class); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:19: Warnung: kein Kommentar -[INFO] protected final Morphium morphium; -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:24: Warnung: kein Kommentar -[INFO] public AbstractCacheSynchronizer(Morphium morphium) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:37: Warnung: kein Kommentar -[INFO] public void addSyncListener(Class type, T cl) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:28: Warnung: kein Kommentar -[INFO] public void addSyncListener(T cl) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:66: Warnung: kein Kommentar -[INFO] public void firePostClearEvent(Class type) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:51: Warnung: kein Kommentar -[INFO] protected void firePreClearEvent(Class type) throws CacheSyncVetoException { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:43: Warnung: kein Kommentar -[INFO] public void removeSyncListener(Class type, T cl) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:32: Warnung: kein Kommentar -[INFO] public void removeSyncListener(T cl) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/AdditionalData.html wird generiert... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/annotations/AdditionalData.java:19: Warnung: kein Kommentar -[INFO] boolean readOnly() default true; -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/AdminMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AdminMongoCommand.java:9: Warnung: kein Kommentar -[INFO] public abstract class AdminMongoCommand extends MongoCommand implements SingleResultCommand { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AdminMongoCommand.java:10: Warnung: kein Kommentar -[INFO] public AdminMongoCommand(MongoConnection d) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/SingleResultCommand.java:8: Warnung: kein Kommentar -[INFO] Map execute() throws MorphiumDriverException; -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java:272: Warnung: kein Kommentar -[INFO] public int executeAsync() throws MorphiumDriverException { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/AESEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/encryption/AESEncryptionProvider.java:7: Warnung: kein Kommentar -[INFO] public class AESEncryptionProvider implements ValueEncryptionProvider { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/encryption/AESEncryptionProvider.java:11: Warnung: kein Kommentar -[INFO] public AESEncryptionProvider() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:14: Warnung: kein Kommentar -[INFO] byte[] decrypt(byte[] input); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:12: Warnung: kein Kommentar -[INFO] byte[] encrypt(byte[] input); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:10: Warnung: kein Kommentar -[INFO] void sedDecryptionKeyBase64(String key); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:8: Warnung: kein Kommentar -[INFO] void setDecryptionKey(byte[] key); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:4: Warnung: kein Kommentar -[INFO] void setEncryptionKey(byte[] key); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:6: Warnung: kein Kommentar -[INFO] void setEncryptionKeyBase64(String key); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/AggregateMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:15: Warnung: kein Kommentar -[INFO] public class AggregateMongoCommand extends ReadMongoCommand implements MultiResultCommand { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:29: Warnung: kein Kommentar -[INFO] public AggregateMongoCommand(MongoConnection d) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/MultiResultCommand.java:15: Warnung: kein Kommentar -[INFO] Map asMap(); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:160: Warnung: kein Kommentar -[INFO] public Map explain(ExplainVerbosity verbosity) throws MorphiumDriverException{ -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java:108: Warnung: kein Kommentar -[INFO] public T fromMap(Map m) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:60: Warnung: kein Kommentar -[INFO] public Boolean getAllowDiskUse() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:33: Warnung: kein Kommentar -[INFO] public Integer getBatchSize() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:78: Warnung: kein Kommentar -[INFO] public Boolean getBypassDocumentValidation() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:96: Warnung: kein Kommentar -[INFO] public Map getCollation() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:132: Warnung: kein Kommentar -[INFO] public Map getCursor() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:51: Warnung: kein Kommentar -[INFO] public Boolean getExplain() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:105: Warnung: kein Kommentar -[INFO] public Object getHint() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:123: Warnung: kein Kommentar -[INFO] public Map getLet() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:69: Warnung: kein Kommentar -[INFO] public Integer getMaxWaitTime() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:42: Warnung: kein Kommentar -[INFO] public List> getPipeline() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:87: Warnung: kein Kommentar -[INFO] public Map getReadConcern() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:114: Warnung: kein Kommentar -[INFO] public Map getWriteConern() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:64: Warnung: kein Kommentar -[INFO] public AggregateMongoCommand setAllowDiskUse(Boolean allowDiskUse) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/AggregationIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/Aggregator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/Aggregator.BucketGranularity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/Aggregator.GeoNearFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/Aggregator.MergeActionWhenMatched.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/Aggregator.MergeActionWhenNotMatched.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/AggregatorImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Aliases.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/AnnotationAndReflectionHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/AppendEntriesRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/AppendEntriesResponse.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/async/AsyncCallbackAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/async/AsyncOperationCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/async/AsyncOperationType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/AsyncWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/AsyncWrites.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/AtomicBooleanMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/AtomicDecimal.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/AtomicIntegerMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/AtomicLongMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/AuthSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/BigDecimalMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/BigIntegerTypeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/BinarySerializedObject.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/BsonDecoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/BsonEncoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/BsonGeoMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/BufferedMorphiumWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/BulkContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/BulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/BulkRequestContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/ByteMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/Cache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/Cache.ClearStrategy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/Cache.SyncCacheStrategy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/CacheEntry.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/CacheEventVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/CacheHousekeeper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/CacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/CacheImpl.CEvent.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/CacheListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/CacheManagerImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/CacheSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/CacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/CacheSyncVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/CachingProviderImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Capped.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/changestream/ChangeStreamEvent.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/changestream/ChangeStreamListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/changestream/ChangeStreamMonitor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/CharacterMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/ClearCollectionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/ClusterSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Collation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Collation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Collation.Alternate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Collation.CaseFirst.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Collation.MaxVariable.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Collation.Strength.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/CollectionCheckSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/CollectionCheckSettings.CappedCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/CollectionCheckSettings.IndexCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/CollectionInfo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/CollStatsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/CommitTransactionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/ConfNode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/ConnectionSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/ConnectionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/Consumer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/Context.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/CountMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/CreateCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/CreateCommand.Granularity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/CreateIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/CreateRoleAdminCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/CreateRoleAdminCommand.Privilege.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/CreateRoleAdminCommand.Resource.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/CreateUserAdminCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/CreationTime.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/CurrentOpCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/CursorResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/DAO.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/DbStatsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/DefaultEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/DefaultNameProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/DefaultReadPreference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/DeleteBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/DeleteMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/DistinctMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/Doc.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Driver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/DriverBase.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/DriverSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/DriverTailableIterationCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/DropDatabaseMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/DropIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/DropMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/ElectionConfig.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/ElectionManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/ElectionNetworkClient.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/ElectionState.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Embedded.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/encryption/Encrypted.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/EncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/EncryptionSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Entity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Entity.ReadConcernLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Entity.ValidationAction.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Entity.ValidationLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/ExplainCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/ExplainCommand.ExplainVerbosity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/Expr.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/Expr.ValueExpr.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/FieldNames.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/FilterExpression.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/FindAndModifyMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/FindCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/FunctionNotSupportedException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/GenericCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/Geo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/GeoType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/GetMoreMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/Group.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/HelloCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/HelloResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/Host.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/HouseKeepingHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Id.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/IgnoreFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/IgnoreNullFromDB.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Index.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/IndexDescription.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/InMemAggregator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/InMemDumpContainer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/InMemoryDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/InMemoryDriver.MapReduceEmitter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/InMemTransactionContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/InsertBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/InsertMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/InstantMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/validation/JavaxValidationStorageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSBytesMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSConnectionConsumer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSConnectionFactory.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSDestination.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSMapMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSObjectMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSQueue.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSSession.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSTextMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/JMSTopic.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/KillCursorsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/LastAccess.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/LastChange.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/LazyDeReferencingProxy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/Lifecycle.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/LimitToFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/LineString.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/ListCollectionsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/ListDatabasesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/ListIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/ListResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/LocalDateMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/LocalDateTimeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/LocalTimeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/MapReduceCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/mongodb/Maximums.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/MessageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/MessageRejectedException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/MessageRejectedException.RejectionHandler.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Messaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/MessagingCacheSyncAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/MessagingCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/MessagingCacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/messaging/MessagingCollectionInfo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/messaging/MessagingOptimizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/MessagingRegistry.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/MessagingSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/MessagingSettings.RecipientCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/MessagingSettings.TopicCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/MongoBob.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/MongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/MongoCommandHandler.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/MongoConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/MongoConnectionThread.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/MongoEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/MongoField.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/MongoFieldImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/MongoJSScript.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/MongoMaxKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/MongoMinKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/MongoTimestamp.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/MongoType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/MongoWireProtocolDecoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/MongoWireProtocolEncoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Morphium.html wird generiert... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:794: Fehler: Nicht abgeschlossenes Inlinetag -[INFO] * Please use {@link Morphium#setInEntity(Object, String, Map) -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1015: Fehler: Nicht wohlgeformte HTML -[INFO] * unmarshalled, you might get MongoMaps -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1140: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1153: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1164: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1243: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1272: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1283: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1296: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1459: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1470: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1481: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumBase.java:1493: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/Morphium.java:810: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use {Morphium{@link #unsetInEntity(Object, String, String, AsyncOperationCallback)} instead. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/Morphium.java:1073: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/Morphium.java:1094: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/Morphium.java:1169: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use {@link Morphium#remove(List, String)} -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/Morphium.java:1178: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use {@link Morphium#remove(List, String, AsyncOperationCallback)} -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/Morphium.java:1672: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated - for read access use {@link Query} instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/MorphiumAccessVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/MorphiumAggregationIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/MorphiumBase.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/bulk/MorphiumBulkContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/MorphiumCache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/MorphiumCacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/MorphiumCacheJCacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumCollection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/MorphiumConfig.html wird generiert... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:940: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:949: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:957: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:966: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:975: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:984: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:993: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:1002: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:1010: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:1018: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/MorphiumConfig.java:1026: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/MorphiumConfig.CompressionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/MorphiumConfigResolver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumCursorAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumDriver.CompressionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumDriver.DriverStatsKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumDriverException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumDriverNetworkException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumDriverOperation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumId.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/MorphiumIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/MorphiumMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/MorphiumObjectMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/MorphiumReference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/MorphiumServer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/MorphiumServerCLI.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/MorphiumStorageAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/MorphiumStorageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/MorphiumStorageListener.UpdateTypes.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/MorphiumTransactionContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/MorphiumTransactionContextImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/MorphiumTypeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/MorphiumWriter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/MorphiumWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/MorphiumWriterImpl.WT.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/Msg.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/Msg.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/MsgLock.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/MsgLock.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/MultiCollectionMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/MultiCollectionMessaging.java:1786: Fehler: @param-Name nicht gefunden -[INFO] * @param timoutInMs milliseconds to wait until listener is removed -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/MultiLineString.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/MultiPoint.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/MultiPolygon.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/MultiResultCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/NameProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/NetworkCallHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/NetworkCallHelper.ErrorCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/NoCache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/ObjectMapperImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/ObjectMappingSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpCompressed.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpDelete.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpGetMore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpInsert.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpKillCursors.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/OplogListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpMsg.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpQuery.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpReply.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/Point.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/Polygon.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/PooledDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/PooledDriver.ConnectionContainer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/PooledDriver.PingStats.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/PostLoad.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/PostRemove.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/PostStore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/PostUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/PreRemove.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/PreStore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/PreUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/Producer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Property.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/Property.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/PropertyEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/Query.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/Query.TextSearchLanguages.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/QueryHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/QueryIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/ReadAccessType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/ReadMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/ReadOnly.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/ReadPreference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/ReadPreferenceLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/ReadPreferenceType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Reference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/RemoveProcessTask.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/RenameCollectionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/ReplicaSetConf.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/ReplicaSetNode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/ReplicaSetStatus.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/ReplicasetStatusListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/ReplicastStatusCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/ReplicationCoordinator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/ReplicationManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/RSAEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/RunCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/RunCommand.Command.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/RunCommand.ErrorCode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/RunCommand.Response.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/RunCommandResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/SafetyLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/SaslAuthCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Sequence.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Sequence.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Sequence.SeqLock.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/SequenceGenerator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/Settings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/ShortMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/ShutdownCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/ShutdownListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/SingleBatchCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/SingleCollectionMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:128: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:139: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:147: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:155: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:172: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:189: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:202: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - processMultiple is unused -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:1835: Fehler: @param-Name nicht gefunden -[INFO] * @param timoutInMs - milliseconds to wait until listener is removed -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/SingleCollectionMessaging.AsyncMessageCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/SingleCollectionMessaging.MessageTimeoutException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/SingleCollectionMessaging.ProcessingQueueElement.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/SingleCollectionMessaging.SystemShutdownException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/SingleElementCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/SingleElementResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/SingleMongoConnectDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/SingleMongoConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/SingleMongoConnectionCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/SingleResultCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/SslHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/StatisticKeys.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Statistics.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/StatisticValue.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/StatusInfoListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/StatusInfoListener.InternalCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/StatusInfoListener.StatusInfoLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/StepDownCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/StoreMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/ThreadPoolSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/ThrowOnError.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/TimestampMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/Transient.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/UpdateBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/UpdateMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/UseIfnull.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/Utils.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/UtilsMap.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/UUIDRepresentation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/ValueEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/VoteRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/VoteResponse.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/WatchCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/WatchCommand.FullDocumentBeforeChangeEnum.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/WatchCommand.FullDocumentEnum.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/WatchCursorManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/WatchingCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/WatchingCacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/WireProtocolMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/WireProtocolMessage.OpCode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/WriteAccessType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/WriteBuffer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/WriteBuffer.STRATEGY.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/WriteConcern.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/WriteMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/WriterSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/WriterTask.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/WriteSafety.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/encryption/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/encryption/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/async/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/async/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/bulk/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/bulk/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/changestream/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/changestream/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/mongodb/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/mongodb/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/messaging/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/messaging/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/validation/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/validation/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/constant-values.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/serialized-form.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/AggregationIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/Group.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/Aggregator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/Aggregator.MergeActionWhenNotMatched.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/Aggregator.MergeActionWhenMatched.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/Aggregator.BucketGranularity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/Aggregator.GeoNearFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/AggregatorImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/MorphiumAggregationIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/Expr.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/class-use/Expr.ValueExpr.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/class-use/AESEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/class-use/RSAEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/class-use/DefaultEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/class-use/EncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/class-use/MongoEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/class-use/ValueEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/class-use/PropertyEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/MorphiumStorageAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Sequence.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Sequence.SeqLock.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Sequence.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/FilterExpression.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/MorphiumStorageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/MorphiumStorageListener.UpdateTypes.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/LazyDeReferencingProxy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/StatisticKeys.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Utils.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/DefaultNameProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/MorphiumBase.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/MorphiumAccessVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/CollectionInfo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/WriteAccessType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Statistics.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/MongoType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/AnnotationAndReflectionHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/DAO.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Collation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Collation.Strength.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Collation.MaxVariable.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Collation.CaseFirst.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Collation.Alternate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/UtilsMap.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/SequenceGenerator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/IndexDescription.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/NameProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/MorphiumReference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/StatisticValue.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/ReadAccessType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/MorphiumConfig.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/MorphiumConfig.CompressionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/ShutdownListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/Morphium.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/ObjectMapperImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/MorphiumConfigResolver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/ThrowOnError.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/class-use/BinarySerializedObject.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/bulk/class-use/MorphiumBulkContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/WatchingCacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/WatchingCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/MessagingCacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/MessagingCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/CacheSyncVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/MorphiumCacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/AbstractCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/CacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/CacheHousekeeper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/MorphiumCache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/MorphiumCacheJCacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/MessagingCacheSyncAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/class-use/CacheListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CacheEventVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CacheManagerImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CacheEntry.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/class-use/HouseKeepingHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CacheImpl.CEvent.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CachingProviderImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/EncryptionSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/MessagingSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/MessagingSettings.RecipientCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/MessagingSettings.TopicCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/DriverSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/AuthSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/ConnectionSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/ClusterSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/ObjectMappingSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/CollectionCheckSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/CollectionCheckSettings.CappedCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/CollectionCheckSettings.IndexCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/ThreadPoolSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/Settings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/CacheSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/class-use/WriterSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/class-use/MongoBob.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/class-use/MongoJSScript.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/class-use/MongoMaxKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/class-use/UUIDRepresentation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/class-use/BsonDecoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/class-use/MongoTimestamp.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/class-use/MongoMinKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/class-use/BsonEncoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumCollection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/FunctionNotSupportedException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriver.CompressionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriver.DriverStatsKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/Doc.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriverNetworkException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/SingleElementCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriverException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumId.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/DriverTailableIterationCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/SingleBatchCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/ReadPreference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/ReadPreferenceType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/WriteConcern.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumCursorAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumTransactionContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriverOperation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/class-use/InsertBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/class-use/BulkRequestContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/class-use/UpdateBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/class-use/DeleteBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/class-use/BulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/SslHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/HelloResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/NetworkCallHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/NetworkCallHelper.ErrorCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/BulkContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/MorphiumTransactionContextImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/ConnectionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/SingleMongoConnectDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/PooledDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/PooledDriver.ConnectionContainer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/PooledDriver.PingStats.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/AtomicDecimal.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/DriverBase.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/MongoConnectionThread.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/SingleMongoConnectionCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/Host.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/MongoConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/class-use/SingleMongoConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/class-use/RunCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/class-use/RunCommand.ErrorCode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/class-use/RunCommand.Command.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/class-use/RunCommand.Response.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/mongodb/class-use/Maximums.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/ReadMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/CreateCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/CreateCommand.Granularity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/InsertMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/GenericCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/DropIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/FindAndModifyMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/MongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/HelloCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/ExplainCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/ExplainCommand.ExplainVerbosity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/GetMoreMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/DistinctMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/StepDownCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/CollStatsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/DbStatsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/KillCursorsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/AggregateMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/DropDatabaseMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/MapReduceCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/ListDatabasesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/CreateIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/UpdateMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/CommitTransactionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/SingleResultCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/AbortTransactionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/WatchCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/WatchCommand.FullDocumentEnum.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/WatchCommand.FullDocumentBeforeChangeEnum.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/ClearCollectionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/DropMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/ShutdownCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/MultiResultCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/RenameCollectionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/WriteMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/FindCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/CountMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/StoreMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/CurrentOpCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/AdminMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/ListCollectionsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/ListIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/ReplicastStatusCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/class-use/DeleteMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/class-use/RunCommandResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/class-use/SingleElementResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/class-use/CursorResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/class-use/ListResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/class-use/SaslAuthCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/class-use/CreateUserAdminCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/class-use/CreateRoleAdminCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/class-use/CreateRoleAdminCommand.Resource.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/class-use/CreateRoleAdminCommand.Privilege.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpMsg.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/WireProtocolMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/WireProtocolMessage.OpCode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpGetMore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpQuery.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpDelete.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpInsert.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpCompressed.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpReply.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpKillCursors.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/class-use/InMemTransactionContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/class-use/InMemoryDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/class-use/InMemoryDriver.MapReduceEmitter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/class-use/InMemDumpContainer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/class-use/InMemAggregator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/class-use/QueryHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/async/class-use/AsyncOperationType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/async/class-use/AsyncCallbackAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/async/class-use/AsyncOperationCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/ShortMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/MorphiumObjectMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/TimestampMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/AtomicBooleanMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/BigDecimalMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/BsonGeoMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/ByteMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/LocalTimeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/LocalDateMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/LocalDateTimeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/CharacterMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/BigIntegerTypeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/AtomicLongMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/AtomicIntegerMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/InstantMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/class-use/MorphiumTypeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/class-use/ReplicationManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/class-use/MorphiumServerCLI.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/class-use/ReplicationCoordinator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/class-use/MorphiumServer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/class-use/MongoWireProtocolEncoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/class-use/MongoCommandHandler.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/class-use/MongoWireProtocolDecoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/class-use/WatchCursorManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/class-use/ElectionNetworkClient.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/class-use/AppendEntriesResponse.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/class-use/VoteRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/class-use/ElectionConfig.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/class-use/AppendEntriesRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/class-use/ElectionState.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/class-use/ElectionManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/class-use/VoteResponse.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/messaging/class-use/MessagingOptimizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/messaging/class-use/MessagingCollectionInfo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/encryption/class-use/Encrypted.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/DefaultReadPreference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Index.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/UseIfnull.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Property.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Id.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Capped.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/SafetyLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/AdditionalData.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Driver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/LimitToFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Reference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/LastChange.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/ReadPreferenceLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Embedded.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Transient.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Collation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/IgnoreNullFromDB.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/CreationTime.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/WriteSafety.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Aliases.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/IgnoreFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Entity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Entity.ReadConcernLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Entity.ValidationLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Entity.ValidationAction.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/Messaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/LastAccess.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/class-use/ReadOnly.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PostStore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PostRemove.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PostLoad.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PreStore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PostUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PreRemove.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/Lifecycle.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PreUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/class-use/WriteBuffer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/class-use/WriteBuffer.STRATEGY.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/class-use/Cache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/class-use/Cache.SyncCacheStrategy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/class-use/Cache.ClearStrategy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/class-use/NoCache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/class-use/AsyncWrites.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/class-use/MorphiumWriter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/class-use/WriterTask.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/class-use/AsyncWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/class-use/BufferedMorphiumWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/class-use/MorphiumWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/class-use/MorphiumWriterImpl.WT.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/class-use/OplogListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/class-use/ReplicasetStatusListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/class-use/ReplicaSetConf.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/class-use/ReplicaSetNode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/class-use/ConfNode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/class-use/ReplicaSetStatus.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/class-use/QueryIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/class-use/Property.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/class-use/MongoFieldImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/class-use/MorphiumIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/class-use/Query.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/class-use/Query.TextSearchLanguages.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/class-use/MongoField.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/class-use/FieldNames.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/class-use/Polygon.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/class-use/Geo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/class-use/MultiPoint.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/class-use/Point.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/class-use/MultiLineString.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/class-use/MultiPolygon.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/class-use/GeoType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/class-use/LineString.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/MessageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/Msg.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/Msg.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/MultiCollectionMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/MorphiumMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/MessagingRegistry.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/MsgLock.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/MsgLock.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/StatusInfoListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/StatusInfoListener.InternalCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/StatusInfoListener.StatusInfoLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/RemoveProcessTask.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/SingleCollectionMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/SingleCollectionMessaging.AsyncMessageCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/SingleCollectionMessaging.ProcessingQueueElement.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/SingleCollectionMessaging.SystemShutdownException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/SingleCollectionMessaging.MessageTimeoutException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/MessageRejectedException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/class-use/MessageRejectedException.RejectionHandler.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSTextMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSMapMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSObjectMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSDestination.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/Context.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSSession.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSConnectionFactory.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSTopic.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/Consumer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/Producer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSQueue.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSConnectionConsumer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSBytesMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/validation/class-use/JavaxValidationStorageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/changestream/class-use/ChangeStreamListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/changestream/class-use/ChangeStreamMonitor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/changestream/class-use/ChangeStreamEvent.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/aggregation/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/caching/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/encryption/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/annotations/lifecycle/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/async/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/bulk/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/cache/jcache/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/changestream/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/config/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bson/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/bulk/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/auth/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/commands/result/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/constants/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/inmem/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/mongodb/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wire/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/driver/wireprotocol/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/encryption/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/messaging/jms/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/objectmapping/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/query/geospatial/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/replicaset/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/election/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/messaging/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/server/netty/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/validation/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/de/caluga/morphium/writer/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/overview-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/deprecated-list.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/index.html wird generiert... -[INFO] Index für alle Klassen wird erstellt... -[INFO] /Users/stephan/develop/morphium/target/apidocs/allclasses-index.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/allpackages-index.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/index-all.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/search.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/overview-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/apidocs/help-doc.html wird generiert... -[INFO] 52 Fehler -[INFO] 100 Warnungen -[INFO] -[INFO] Command line was: /usr/bin/javadoc -J-Xmx2048m @options @packages -[INFO] -[INFO] Refer to the generated Javadoc files in '/Users/stephan/develop/morphium/target/apidocs' dir. -[INFO] -[INFO] at org.apache.maven.plugins.javadoc.AbstractJavadocMojo.doExecuteJavadocCommandLine (AbstractJavadocMojo.java:6092) -[INFO] at org.apache.maven.plugins.javadoc.AbstractJavadocMojo.executeJavadocCommandLine (AbstractJavadocMojo.java:5968) -[INFO] at org.apache.maven.plugins.javadoc.AbstractJavadocMojo.executeReport (AbstractJavadocMojo.java:2277) -[INFO] at org.apache.maven.plugins.javadoc.JavadocJar.doExecute (JavadocJar.java:189) -[INFO] at org.apache.maven.plugins.javadoc.AbstractJavadocMojo.execute (AbstractJavadocMojo.java:2034) -[INFO] at org.apache.maven.plugin.DefaultBuildPluginManager.executeMojo (DefaultBuildPluginManager.java:126) -[INFO] at org.apache.maven.lifecycle.internal.MojoExecutor.doExecute2 (MojoExecutor.java:328) -[INFO] at org.apache.maven.lifecycle.internal.MojoExecutor.doExecute (MojoExecutor.java:316) -[INFO] at org.apache.maven.lifecycle.internal.MojoExecutor.execute (MojoExecutor.java:212) -[INFO] at org.apache.maven.lifecycle.internal.MojoExecutor.execute (MojoExecutor.java:174) -[INFO] at org.apache.maven.lifecycle.internal.MojoExecutor.access$000 (MojoExecutor.java:75) -[INFO] at org.apache.maven.lifecycle.internal.MojoExecutor$1.run (MojoExecutor.java:162) -[INFO] at org.apache.maven.plugin.DefaultMojosExecutionStrategy.execute (DefaultMojosExecutionStrategy.java:39) -[INFO] at org.apache.maven.lifecycle.internal.MojoExecutor.execute (MojoExecutor.java:159) -[INFO] at org.apache.maven.lifecycle.internal.LifecycleModuleBuilder.buildProject (LifecycleModuleBuilder.java:105) -[INFO] at org.apache.maven.lifecycle.internal.LifecycleModuleBuilder.buildProject (LifecycleModuleBuilder.java:73) -[INFO] at org.apache.maven.lifecycle.internal.builder.singlethreaded.SingleThreadedBuilder.build (SingleThreadedBuilder.java:53) -[INFO] at org.apache.maven.lifecycle.internal.LifecycleStarter.execute (LifecycleStarter.java:118) -[INFO] at org.apache.maven.DefaultMaven.doExecute (DefaultMaven.java:261) -[INFO] at org.apache.maven.DefaultMaven.doExecute (DefaultMaven.java:173) -[INFO] at org.apache.maven.DefaultMaven.execute (DefaultMaven.java:101) -[INFO] at org.apache.maven.cli.MavenCli.execute (MavenCli.java:919) -[INFO] at org.apache.maven.cli.MavenCli.doMain (MavenCli.java:285) -[INFO] at org.apache.maven.cli.MavenCli.main (MavenCli.java:207) -[INFO] at jdk.internal.reflect.DirectMethodHandleAccessor.invoke (DirectMethodHandleAccessor.java:103) -[INFO] at java.lang.reflect.Method.invoke (Method.java:580) -[INFO] at org.codehaus.plexus.classworlds.launcher.Launcher.launchEnhanced (Launcher.java:255) -[INFO] at org.codehaus.plexus.classworlds.launcher.Launcher.launch (Launcher.java:201) -[INFO] at org.codehaus.plexus.classworlds.launcher.Launcher.mainWithExitCode (Launcher.java:361) -[INFO] at org.codehaus.plexus.classworlds.launcher.Launcher.main (Launcher.java:314) -[INFO] [INFO] Building jar: /Users/stephan/develop/morphium/target/morphium-6.1.6-javadoc.jar -[INFO] [INFO] -[INFO] [INFO] --- assembly:3.7.1:single (make-assembly) @ morphium --- -[INFO] [INFO] Reading assembly descriptor: src/main/assembly/server-cli.xml -[INFO] [INFO] Building jar: /Users/stephan/develop/morphium/target/morphium-6.1.6-server-cli.jar -[INFO] [INFO] ------------------------------------------------------------------------ -[INFO] [INFO] BUILD SUCCESS -[INFO] [INFO] ------------------------------------------------------------------------ -[INFO] [INFO] Total time: 13.121 s -[INFO] [INFO] Finished at: 2026-01-28T09:28:21+01:00 -[INFO] [INFO] ------------------------------------------------------------------------ -[INFO] Checking in modified POMs... -[INFO] Executing: /bin/sh -c cd /Users/stephan/develop/morphium && git add -- pom.xml -[INFO] Working directory: /Users/stephan/develop/morphium -[INFO] Executing: /bin/sh -c cd /Users/stephan/develop/morphium && git rev-parse --show-toplevel -[INFO] Working directory: /Users/stephan/develop/morphium -[INFO] Executing: /bin/sh -c cd /Users/stephan/develop/morphium && git status --porcelain . -[INFO] Working directory: /Users/stephan/develop/morphium -[WARNING] Ignoring unrecognized line: ?? logs/ -[INFO] Executing: /bin/sh -c cd /Users/stephan/develop/morphium && git commit --verbose -F /var/folders/k3/1d94y2s92y52ydlb411knzs00000gn/T/maven-scm-881859495.commit pom.xml -[INFO] Working directory: /Users/stephan/develop/morphium -[INFO] Executing: /bin/sh -c cd /Users/stephan/develop/morphium && git symbolic-ref HEAD -[INFO] Working directory: /Users/stephan/develop/morphium -[INFO] Executing: /bin/sh -c cd /Users/stephan/develop/morphium && git push git@github.com:sboesebeck/morphium.git refs/heads/develop:refs/heads/develop -[INFO] Working directory: /Users/stephan/develop/morphium -[INFO] Tagging release with the label v6.1.6... -[INFO] Executing: /bin/sh -c cd /Users/stephan/develop/morphium && git tag -F /var/folders/k3/1d94y2s92y52ydlb411knzs00000gn/T/maven-scm-972691146.commit v6.1.6 -[INFO] Working directory: /Users/stephan/develop/morphium -[INFO] Executing: /bin/sh -c cd /Users/stephan/develop/morphium && git push git@github.com:sboesebeck/morphium.git refs/tags/v6.1.6 -[INFO] Working directory: /Users/stephan/develop/morphium -[INFO] Executing: /bin/sh -c cd /Users/stephan/develop/morphium && git ls-files -[INFO] Working directory: /Users/stephan/develop/morphium -[INFO] Transforming 'Morphium'... -[INFO] Not removing release POMs -[INFO] Checking in modified POMs... -[INFO] Executing: /bin/sh -c cd /Users/stephan/develop/morphium && git add -- pom.xml -[INFO] Working directory: /Users/stephan/develop/morphium -[INFO] Executing: /bin/sh -c cd /Users/stephan/develop/morphium && git rev-parse --show-toplevel -[INFO] Working directory: /Users/stephan/develop/morphium -[INFO] Executing: /bin/sh -c cd /Users/stephan/develop/morphium && git status --porcelain . -[INFO] Working directory: /Users/stephan/develop/morphium -[WARNING] Ignoring unrecognized line: ?? logs/ -[INFO] Executing: /bin/sh -c cd /Users/stephan/develop/morphium && git commit --verbose -F /var/folders/k3/1d94y2s92y52ydlb411knzs00000gn/T/maven-scm-305657942.commit pom.xml -[INFO] Working directory: /Users/stephan/develop/morphium -[INFO] Executing: /bin/sh -c cd /Users/stephan/develop/morphium && git symbolic-ref HEAD -[INFO] Working directory: /Users/stephan/develop/morphium -[INFO] Executing: /bin/sh -c cd /Users/stephan/develop/morphium && git push git@github.com:sboesebeck/morphium.git refs/heads/develop:refs/heads/develop -[INFO] Working directory: /Users/stephan/develop/morphium -[INFO] Release preparation complete. -[INFO] ------------------------------------------------------------------------ -[INFO] BUILD SUCCESS -[INFO] ------------------------------------------------------------------------ -[INFO] Total time: 24.307 s -[INFO] Finished at: 2026-01-28T09:28:23+01:00 -[INFO] ------------------------------------------------------------------------ -[INFO] Scanning for projects... -[INFO] -[INFO] -------------------------< de.caluga:morphium >------------------------- -[INFO] Building Morphium 6.1.7-SNAPSHOT -[INFO] from pom.xml -[INFO] --------------------------------[ jar ]--------------------------------- -[INFO] -[INFO] --- release:2.5.3:perform (default-cli) @ morphium --- -[INFO] Checking out the project to perform the release ... -[INFO] Executing: /bin/sh -c cd /Users/stephan/develop/morphium/target && git clone --branch v6.1.6 git@github.com:sboesebeck/morphium.git /Users/stephan/develop/morphium/target/checkout -[INFO] Working directory: /Users/stephan/develop/morphium/target -[INFO] Executing: /bin/sh -c cd /var/folders/k3/1d94y2s92y52ydlb411knzs00000gn/T/ && git ls-remote git@github.com:sboesebeck/morphium.git -[INFO] Working directory: /var/folders/k3/1d94y2s92y52ydlb411knzs00000gn/T -[INFO] Executing: /bin/sh -c cd /Users/stephan/develop/morphium/target/checkout && git fetch git@github.com:sboesebeck/morphium.git -[INFO] Working directory: /Users/stephan/develop/morphium/target/checkout -[INFO] Executing: /bin/sh -c cd /Users/stephan/develop/morphium/target/checkout && git checkout v6.1.6 -[INFO] Working directory: /Users/stephan/develop/morphium/target/checkout -[INFO] Executing: /bin/sh -c cd /Users/stephan/develop/morphium/target/checkout && git ls-files -[INFO] Working directory: /Users/stephan/develop/morphium/target/checkout -[INFO] Invoking perform goals in directory /Users/stephan/develop/morphium/target/checkout -[INFO] Executing goals 'deploy'... -[WARNING] Maven will be executed in interactive mode, but no input stream has been configured for this MavenInvoker instance. -[INFO] [INFO] Scanning for projects... -[INFO] [WARNING] -[INFO] [WARNING] Some problems were encountered while building the effective model for de.caluga:morphium:jar:6.1.6 -[INFO] [WARNING] 'build.plugins.plugin.version' for org.apache.maven.plugins:maven-deploy-plugin is missing. @ org.apache.maven:maven-model-builder:3.9.12:super-pom, jar:file:/opt/homebrew/Cellar/maven/3.9.12/libexec/lib/maven-model-builder-3.9.12.jar!/org/apache/maven/model/pom-4.0.0.xml, line 134, column 19 -[INFO] [WARNING] -[INFO] [WARNING] It is highly recommended to fix these problems because they threaten the stability of your build. -[INFO] [WARNING] -[INFO] [WARNING] For this reason, future Maven versions might no longer support building such malformed projects. -[INFO] [WARNING] -[INFO] [INFO] -[INFO] [INFO] -------------------------< de.caluga:morphium >------------------------- -[INFO] [INFO] Building Morphium 6.1.6 -[INFO] [INFO] from pom.xml -[INFO] [INFO] --------------------------------[ jar ]--------------------------------- -[INFO] [INFO] -[INFO] [INFO] --- resources:3.3.1:resources (default-resources) @ morphium --- -[INFO] [INFO] Copying 1 resource from src/main/resources to target/classes -[INFO] [INFO] -[INFO] [INFO] --- compiler:3.12.1:compile (default-compile) @ morphium --- -[INFO] [INFO] Recompiling the module because of changed source code. -[INFO] [INFO] Compiling 315 source files with javac [debug release 21] to target/classes -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/ObjectMapperImpl.java:[22,19] sun.reflect.ReflectionFactory ist eine interne proprietäre API, die in einem zukünftigen Release entfernt werden kann -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/aggregation/Expr.java:[91,62] Nicht-varargs-Aufruf von varargs-Methode mit ungenauem Argumenttyp für den letzten Parameter. -[INFO] Führen Sie für einen varargs-Aufruf eine Umwandlung mit Cast in java.lang.Object aus -[INFO] Führen Sie für einen Nicht-varargs-Aufruf eine Umwandlung mit Cast in java.lang.Object[] aus, um diese Warnung zu unterdrücken -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:[281,65] Nicht-varargs-Aufruf von varargs-Methode mit ungenauem Argumenttyp für den letzten Parameter. -[INFO] Führen Sie für einen varargs-Aufruf eine Umwandlung mit Cast in java.lang.Class aus -[INFO] Führen Sie für einen Nicht-varargs-Aufruf eine Umwandlung mit Cast in java.lang.Class[] aus, um diese Warnung zu unterdrücken -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/ObjectMapperImpl.java:[54,19] sun.reflect.ReflectionFactory ist eine interne proprietäre API, die in einem zukünftigen Release entfernt werden kann -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/ObjectMapperImpl.java:[54,50] sun.reflect.ReflectionFactory ist eine interne proprietäre API, die in einem zukünftigen Release entfernt werden kann -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/objectmapping/ByteMapper.java:[13,16] Byte(byte) in java.lang.Byte ist veraltet und wurde zum Entfernen markiert -[INFO] [INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java: Einige Eingabedateien verwenden oder überschreiben eine veraltete API. -[INFO] [INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java: Wiederholen Sie die Kompilierung mit -Xlint:deprecation, um Details zu erhalten. -[INFO] [INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java: Einige Eingabedateien verwenden nicht geprüfte oder unsichere Vorgänge. -[INFO] [INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java: Wiederholen Sie die Kompilierung mit -Xlint:unchecked, um Details zu erhalten. -[INFO] [INFO] -[INFO] [INFO] --- resources:3.3.1:testResources (default-testResources) @ morphium --- -[INFO] [INFO] Not copying test resources -[INFO] [INFO] -[INFO] [INFO] --- compiler:3.12.1:testCompile (default-testCompile) @ morphium --- -[INFO] [INFO] Recompiling the module because of changed dependency. -[INFO] [INFO] Compiling 226 source files with javac [debug release 21] to target/test-classes -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/mongo/suite/base/ListTests.java:[292,21] Integer(int) in java.lang.Integer ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/morphium/driver/SingleMongoConnectionTest.java:[126,25] Character(char) in java.lang.Character ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/morphium/driver/SingleMongoConnectionTest.java:[127,25] Long(long) in java.lang.Long ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/morphium/driver/SingleMongoConnectionTest.java:[128,28] Integer(int) in java.lang.Integer ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/morphium/driver/SingleMongoConnectionTest.java:[129,26] Float(double) in java.lang.Float ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/morphium/driver/SingleMongoConnectionTest.java:[130,27] Double(double) in java.lang.Double ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/morphium/driver/SingleMongoConnectionTest.java:[132,28] Boolean(boolean) in java.lang.Boolean ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/morphium/driver/SingleMongoConnectionTest.java:[133,25] Byte(byte) in java.lang.Byte ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/morphium/driver/SingleMongoConnectionTest.java:[134,26] Short(short) in java.lang.Short ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/mongo/suite/base/ChangeStreamTest.java:[458,23] Integer(int) in java.lang.Integer ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/mongo/suite/base/CustomMapperTest.java:[60,25] Character(char) in java.lang.Character ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/mongo/suite/base/CustomMapperTest.java:[61,25] Long(long) in java.lang.Long ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/mongo/suite/base/CustomMapperTest.java:[62,28] Integer(int) in java.lang.Integer ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/mongo/suite/base/CustomMapperTest.java:[63,26] Float(double) in java.lang.Float ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/mongo/suite/base/CustomMapperTest.java:[64,27] Double(double) in java.lang.Double ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/mongo/suite/base/CustomMapperTest.java:[66,28] Boolean(boolean) in java.lang.Boolean ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/mongo/suite/base/CustomMapperTest.java:[67,25] Byte(byte) in java.lang.Byte ist veraltet und wurde zum Entfernen markiert -[INFO] [WARNING] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/mongo/suite/base/CustomMapperTest.java:[68,26] Short(short) in java.lang.Short ist veraltet und wurde zum Entfernen markiert -[INFO] [INFO] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/mongo/suite/base/BasicAdminTests.java: Einige Eingabedateien verwenden oder überschreiben eine veraltete API. -[INFO] [INFO] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/mongo/suite/base/BasicAdminTests.java: Wiederholen Sie die Kompilierung mit -Xlint:deprecation, um Details zu erhalten. -[INFO] [INFO] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/mongo/suite/base/ListTests.java: Einige Eingabedateien verwenden nicht geprüfte oder unsichere Vorgänge. -[INFO] [INFO] /Users/stephan/develop/morphium/target/checkout/src/test/java/de/caluga/test/mongo/suite/base/ListTests.java: Wiederholen Sie die Kompilierung mit -Xlint:unchecked, um Details zu erhalten. -[INFO] [INFO] -[INFO] [INFO] --- surefire:3.0.0:test (default-test) @ morphium --- -[INFO] [INFO] Tests are skipped. -[INFO] [INFO] -[INFO] [INFO] --- jar:3.2.2:jar (default-jar) @ morphium --- -[INFO] [INFO] Building jar: /Users/stephan/develop/morphium/target/checkout/target/morphium-6.1.6.jar -[INFO] [INFO] -[INFO] [INFO] >>> source:3.1.0:jar (attach-sources) > generate-sources @ morphium >>> -[INFO] [INFO] -[INFO] [INFO] <<< source:3.1.0:jar (attach-sources) < generate-sources @ morphium <<< -[INFO] [INFO] -[INFO] [INFO] -[INFO] [INFO] --- source:3.1.0:jar (attach-sources) @ morphium --- -[INFO] [INFO] Building jar: /Users/stephan/develop/morphium/target/checkout/target/morphium-6.1.6-sources.jar -[INFO] [INFO] -[INFO] [INFO] --- source:3.1.0:jar-no-fork (attach-sources) @ morphium --- -[INFO] [INFO] Building jar: /Users/stephan/develop/morphium/target/checkout/target/morphium-6.1.6-sources.jar -[INFO] [WARNING] artifact de.caluga:morphium:java-source:sources:6.1.6 already attached, replace previous instance -[INFO] [INFO] -[INFO] [INFO] --- javadoc:3.4.1:jar (attach-javadocs) @ morphium --- -[INFO] [INFO] No previous run data found, generating javadoc. -[INFO] [ERROR] MavenReportException: Error while generating Javadoc: -[INFO] Exit code: 1 - Quelldateien werden geladen für Package de.caluga.morphium.aggregation... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.encryption... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.bulk... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.cache... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.cache.jcache... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.config... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.bson... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.bulk... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.wire... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.constants... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.mongodb... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.commands... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.commands.result... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.commands.auth... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.wireprotocol... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.inmem... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.async... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.objectmapping... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.server... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.server.netty... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.server.election... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.server.messaging... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.annotations.encryption... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.annotations... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.annotations.lifecycle... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.annotations.caching... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.writer... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.replicaset... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.query... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.query.geospatial... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.messaging... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.messaging.jms... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.validation... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.changestream... -[INFO] Javadoc-Informationen werden erstellt... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/ObjectMapperImpl.java:22: Warnung: ReflectionFactory ist eine interne proprietäre API, die in einem zukünftigen Release entfernt werden kann -[INFO] import sun.reflect.ReflectionFactory; -[INFO] ^ -[INFO] Index für alle Packages und Klassen wird erstellt... -[INFO] Standard-Doclet-Version 21.0.9+10-LTS -[INFO] Baum für alle Packages und Klassen wird erstellt... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/aggregation/AggregationIterator.java:20: Warnung: kein @param für -[INFO] public class AggregationIterator implements MorphiumAggregationIterator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/aggregation/AggregationIterator.java:20: Warnung: kein @param für -[INFO] public class AggregationIterator implements MorphiumAggregationIterator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/aggregation/Group.java:19: Warnung: kein @param für -[INFO] public class Group { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/aggregation/Group.java:19: Warnung: kein @param für -[INFO] public class Group { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/aggregation/Aggregator.java:31: Warnung: kein @param für -[INFO] public interface Aggregator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/aggregation/Aggregator.java:31: Warnung: kein @param für -[INFO] public interface Aggregator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/aggregation/AggregatorImpl.java:28: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/aggregation/AggregatorImpl.java:31: Warnung: kein @param für -[INFO] public class AggregatorImpl implements Aggregator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/aggregation/AggregatorImpl.java:31: Warnung: kein @param für -[INFO] public class AggregatorImpl implements Aggregator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumStorageAdapter.java:15: Warnung: kein @param für -[INFO] public abstract class MorphiumStorageAdapter implements MorphiumStorageListener { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumStorageListener.java:13: Warnung: Keine Hauptbeschreibung -[INFO] * @author stephan -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumStorageListener.java:16: Warnung: kein @param für -[INFO] public interface MorphiumStorageListener { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/StatisticKeys.java:7: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/DAO.java:9: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/DAO.java:12: Warnung: kein @param für -[INFO] public abstract class DAO { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/ObjectMapperImpl.java:49: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/bulk/MorphiumBulkContext.java:24: Warnung: kein @param für -[INFO] public class MorphiumBulkContext { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:17: Warnung: kein @param für -[INFO] public abstract class AbstractCacheSynchronizer { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/CacheSyncVetoException.java:7: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/CacheSyncListener.java:7: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/MorphiumCacheJCacheImpl.java:28: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/MessagingCacheSyncAdapter.java:9: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/jcache/CacheEntry.java:10: Warnung: kein @param für -[INFO] public class CacheEntry { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/jcache/CacheImpl.java:26: Warnung: kein @param für -[INFO] public class CacheImpl implements Cache> { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/jcache/CacheImpl.java:26: Warnung: kein @param für -[INFO] public class CacheImpl implements Cache> { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/MorphiumDriverOperation.java:7: Warnung: kein @param für -[INFO] public interface MorphiumDriverOperation { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/wire/NetworkCallHelper.java:18: Warnung: kein @param für -[INFO] public class NetworkCallHelper { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/wire/DriverBase.java:25: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/wireprotocol/OpMsg.java:28: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/async/AsyncOperationCallback.java:14: Warnung: kein @param für -[INFO] public interface AsyncOperationCallback { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/objectmapping/MorphiumTypeMapper.java:10: Warnung: kein @param für -[INFO] public interface MorphiumTypeMapper { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/Index.java:15: Fehler: Überschrift in der falschen Reihenfolge verwendet:

, im Vergleich zur impliziten vorhergehenden Überschrift:

-[INFO] *

Single-Field Indexes

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/UseIfnull.java:13: Warnung: Keine Hauptbeschreibung -[INFO] * @Deprecated This annotation is deprecated. The default behavior has been changed to accept null values -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/UseIfnull.java:13: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated This annotation is deprecated. The default behavior has been changed to accept null values -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/UseIfnull.java:17: Fehler: Überschrift in der falschen Reihenfolge verwendet:

, im Vergleich zur impliziten vorhergehenden Überschrift:

-[INFO] *

Migration Guide:

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/IgnoreNullFromDB.java:15: Fehler: Überschrift in der falschen Reihenfolge verwendet:

, im Vergleich zur impliziten vorhergehenden Überschrift:

-[INFO] *

Behavior Summary:

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/CreationTime.java:16: Warnung: leeres -Tag -[INFO] * -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/CreationTime.java:16: Fehler: Element nicht geschlossen: code -[INFO] * -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/CreationTime.java:18: Fehler: unbekanntes Tag: CreationTime -[INFO] * @CreationTime class Test { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/CreationTime.java:19: Fehler: unbekanntes Tag: CreationTime -[INFO] * @CreationTime private long theTimestamp; -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/CreationTime.java:22: Fehler: unerwartetes Endtag: -[INFO] * -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/WriteSafety.java:16: Fehler: ungültiges Endtag:
-[INFO] *
-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/Aliases.java:15: Fehler: Element nicht geschlossen: code -[INFO] * -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/Aliases.java:18: Fehler: unbekanntes Tag: Aliases -[INFO] * @Aliases("alias","hugo") private String value; -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/Aliases.java:21: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/Aliases.java:22: Fehler: unerwartetes Endtag: -[INFO] * -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/Entity.java:16: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/writer/WriterTask.java:15: Warnung: kein @param für -[INFO] public interface WriterTask extends Runnable { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/writer/AsyncWriterImpl.java:15: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/query/QueryIterator.java:23: Warnung: kein @param für -[INFO] public class QueryIterator implements MorphiumIterator, Iterator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/query/MongoFieldImpl.java:29: Warnung: kein @param für -[INFO] public class MongoFieldImpl implements MongoField { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/query/MorphiumIterator.java:24: Warnung: kein @param für -[INFO] public interface MorphiumIterator extends Iterable, Iterator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/query/Query.java:61: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/query/Query.java:64: Warnung: kein @param für -[INFO] public class Query implements Cloneable { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/query/MongoField.java:22: Warnung: kein @param für -[INFO] public interface MongoField { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/MessageListener.java:7: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/MessageListener.java:9: Warnung: kein @param für -[INFO] public interface MessageListener { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/Msg.java:19: Fehler: ungültiges Endtag:
-[INFO] *
-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/AbortTransactionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java:67: Warnung: keine Beschreibung für @return -[INFO] * @return -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:9: Warnung: kein Kommentar -[INFO] public class AbortTransactionCommand extends AdminMongoCommand{ -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:14: Warnung: kein Kommentar -[INFO] public AbortTransactionCommand(MongoConnection d) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java:185: Warnung: kein Kommentar -[INFO] public Map asMap() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/SingleResultCommand.java:10: Warnung: kein Kommentar -[INFO] Map asMap(); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java:270: Warnung: kein Kommentar -[INFO] public abstract String getCommandName(); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:39: Warnung: kein Kommentar -[INFO] public UUID getLsid() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:48: Warnung: kein Kommentar -[INFO] public long getTxnNumber() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:30: Warnung: kein Kommentar -[INFO] public boolean isAutocommit() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:34: Warnung: kein Kommentar -[INFO] public AbortTransactionCommand setAutocommit(boolean autocommit) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:43: Warnung: kein Kommentar -[INFO] public AbortTransactionCommand setLsid(UUID lsid) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:52: Warnung: kein Kommentar -[INFO] public AbortTransactionCommand setTxnNumber(long txnNumber) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/AbstractCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:21: Warnung: kein Kommentar -[INFO] protected final Hashtable, Vector> listenerForType = new Hashtable<>(); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:20: Warnung: kein Kommentar -[INFO] protected final List listeners = Collections.synchronizedList(new ArrayList<>()); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:18: Warnung: kein Kommentar -[INFO] protected static final Logger log = LoggerFactory.getLogger(MessagingCacheSynchronizer.class); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:19: Warnung: kein Kommentar -[INFO] protected final Morphium morphium; -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:24: Warnung: kein Kommentar -[INFO] public AbstractCacheSynchronizer(Morphium morphium) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:37: Warnung: kein Kommentar -[INFO] public void addSyncListener(Class type, T cl) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:28: Warnung: kein Kommentar -[INFO] public void addSyncListener(T cl) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:66: Warnung: kein Kommentar -[INFO] public void firePostClearEvent(Class type) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:51: Warnung: kein Kommentar -[INFO] protected void firePreClearEvent(Class type) throws CacheSyncVetoException { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:43: Warnung: kein Kommentar -[INFO] public void removeSyncListener(Class type, T cl) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:32: Warnung: kein Kommentar -[INFO] public void removeSyncListener(T cl) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/AdditionalData.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/AdditionalData.java:19: Warnung: kein Kommentar -[INFO] boolean readOnly() default true; -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/AdminMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AdminMongoCommand.java:9: Warnung: kein Kommentar -[INFO] public abstract class AdminMongoCommand extends MongoCommand implements SingleResultCommand { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AdminMongoCommand.java:10: Warnung: kein Kommentar -[INFO] public AdminMongoCommand(MongoConnection d) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/SingleResultCommand.java:8: Warnung: kein Kommentar -[INFO] Map execute() throws MorphiumDriverException; -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java:272: Warnung: kein Kommentar -[INFO] public int executeAsync() throws MorphiumDriverException { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/AESEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/encryption/AESEncryptionProvider.java:7: Warnung: kein Kommentar -[INFO] public class AESEncryptionProvider implements ValueEncryptionProvider { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/encryption/AESEncryptionProvider.java:11: Warnung: kein Kommentar -[INFO] public AESEncryptionProvider() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:14: Warnung: kein Kommentar -[INFO] byte[] decrypt(byte[] input); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:12: Warnung: kein Kommentar -[INFO] byte[] encrypt(byte[] input); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:10: Warnung: kein Kommentar -[INFO] void sedDecryptionKeyBase64(String key); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:8: Warnung: kein Kommentar -[INFO] void setDecryptionKey(byte[] key); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:4: Warnung: kein Kommentar -[INFO] void setEncryptionKey(byte[] key); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:6: Warnung: kein Kommentar -[INFO] void setEncryptionKeyBase64(String key); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/AggregateMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:15: Warnung: kein Kommentar -[INFO] public class AggregateMongoCommand extends ReadMongoCommand implements MultiResultCommand { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:29: Warnung: kein Kommentar -[INFO] public AggregateMongoCommand(MongoConnection d) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/MultiResultCommand.java:15: Warnung: kein Kommentar -[INFO] Map asMap(); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:160: Warnung: kein Kommentar -[INFO] public Map explain(ExplainVerbosity verbosity) throws MorphiumDriverException{ -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java:108: Warnung: kein Kommentar -[INFO] public T fromMap(Map m) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:60: Warnung: kein Kommentar -[INFO] public Boolean getAllowDiskUse() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:33: Warnung: kein Kommentar -[INFO] public Integer getBatchSize() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:78: Warnung: kein Kommentar -[INFO] public Boolean getBypassDocumentValidation() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:96: Warnung: kein Kommentar -[INFO] public Map getCollation() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:132: Warnung: kein Kommentar -[INFO] public Map getCursor() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:51: Warnung: kein Kommentar -[INFO] public Boolean getExplain() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:105: Warnung: kein Kommentar -[INFO] public Object getHint() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:123: Warnung: kein Kommentar -[INFO] public Map getLet() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:69: Warnung: kein Kommentar -[INFO] public Integer getMaxWaitTime() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:42: Warnung: kein Kommentar -[INFO] public List> getPipeline() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:87: Warnung: kein Kommentar -[INFO] public Map getReadConcern() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:114: Warnung: kein Kommentar -[INFO] public Map getWriteConern() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:64: Warnung: kein Kommentar -[INFO] public AggregateMongoCommand setAllowDiskUse(Boolean allowDiskUse) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/AggregationIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/Aggregator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/Aggregator.BucketGranularity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/Aggregator.GeoNearFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/Aggregator.MergeActionWhenMatched.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/Aggregator.MergeActionWhenNotMatched.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/AggregatorImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Aliases.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/AnnotationAndReflectionHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/AppendEntriesRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/AppendEntriesResponse.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/async/AsyncCallbackAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/async/AsyncOperationCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/async/AsyncOperationType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/AsyncWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/AsyncWrites.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/AtomicBooleanMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/AtomicDecimal.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/AtomicIntegerMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/AtomicLongMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/AuthSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/BigDecimalMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/BigIntegerTypeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/BinarySerializedObject.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/BsonDecoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/BsonEncoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/BsonGeoMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/BufferedMorphiumWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/BulkContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/BulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/BulkRequestContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/ByteMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/Cache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/Cache.ClearStrategy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/Cache.SyncCacheStrategy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/CacheEntry.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/CacheEventVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/CacheHousekeeper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/CacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/CacheImpl.CEvent.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/CacheListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/CacheManagerImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/CacheSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/CacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/CacheSyncVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/CachingProviderImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Capped.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/changestream/ChangeStreamEvent.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/changestream/ChangeStreamListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/changestream/ChangeStreamMonitor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/CharacterMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/ClearCollectionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/ClusterSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Collation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Collation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Collation.Alternate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Collation.CaseFirst.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Collation.MaxVariable.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Collation.Strength.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/CollectionCheckSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/CollectionCheckSettings.CappedCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/CollectionCheckSettings.IndexCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/CollectionInfo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/CollStatsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/CommitTransactionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/ConfNode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/ConnectionSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/ConnectionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/Consumer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/Context.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/CountMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/CreateCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/CreateCommand.Granularity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/CreateIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/CreateRoleAdminCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/CreateRoleAdminCommand.Privilege.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/CreateRoleAdminCommand.Resource.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/CreateUserAdminCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/CreationTime.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/CurrentOpCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/CursorResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/DAO.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/DbStatsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/DefaultEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/DefaultNameProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/DefaultReadPreference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/DeleteBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/DeleteMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/DistinctMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/Doc.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Driver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/DriverBase.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/DriverSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/DriverTailableIterationCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/DropDatabaseMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/DropIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/DropMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/ElectionConfig.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/ElectionManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/ElectionNetworkClient.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/ElectionState.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Embedded.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/encryption/Encrypted.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/EncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/EncryptionSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Entity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Entity.ReadConcernLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Entity.ValidationAction.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Entity.ValidationLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/ExplainCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/ExplainCommand.ExplainVerbosity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/Expr.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/Expr.ValueExpr.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/FieldNames.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/FilterExpression.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/FindAndModifyMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/FindCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/FunctionNotSupportedException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/GenericCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/Geo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/GeoType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/GetMoreMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/Group.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/HelloCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/HelloResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/Host.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/HouseKeepingHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Id.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/IgnoreFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/IgnoreNullFromDB.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Index.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/IndexDescription.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/InMemAggregator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/InMemDumpContainer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/InMemoryDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/InMemoryDriver.MapReduceEmitter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/InMemTransactionContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/InsertBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/InsertMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/InstantMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/validation/JavaxValidationStorageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSBytesMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSConnectionConsumer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSConnectionFactory.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSDestination.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSMapMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSObjectMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSQueue.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSSession.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSTextMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSTopic.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/KillCursorsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/LastAccess.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/LastChange.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/LazyDeReferencingProxy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/Lifecycle.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/LimitToFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/LineString.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/ListCollectionsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/ListDatabasesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/ListIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/ListResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/LocalDateMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/LocalDateTimeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/LocalTimeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/MapReduceCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/mongodb/Maximums.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/MessageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/MessageRejectedException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/MessageRejectedException.RejectionHandler.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Messaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/MessagingCacheSyncAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/MessagingCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/MessagingCacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/messaging/MessagingCollectionInfo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/messaging/MessagingOptimizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/MessagingRegistry.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/MessagingSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/MessagingSettings.RecipientCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/MessagingSettings.TopicCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/MongoBob.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/MongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/MongoCommandHandler.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/MongoConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/MongoConnectionThread.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/MongoEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/MongoField.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/MongoFieldImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/MongoJSScript.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/MongoMaxKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/MongoMinKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/MongoTimestamp.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/MongoType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/MongoWireProtocolDecoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/MongoWireProtocolEncoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Morphium.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:794: Fehler: Nicht abgeschlossenes Inlinetag -[INFO] * Please use {@link Morphium#setInEntity(Object, String, Map) -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1015: Fehler: Nicht wohlgeformte HTML -[INFO] * unmarshalled, you might get MongoMaps -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1140: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1153: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1164: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1243: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1272: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1283: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1296: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1459: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1470: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1481: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1493: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/Morphium.java:810: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use {Morphium{@link #unsetInEntity(Object, String, String, AsyncOperationCallback)} instead. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/Morphium.java:1073: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/Morphium.java:1094: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/Morphium.java:1169: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use {@link Morphium#remove(List, String)} -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/Morphium.java:1178: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use {@link Morphium#remove(List, String, AsyncOperationCallback)} -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/Morphium.java:1672: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated - for read access use {@link Query} instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/MorphiumAccessVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/MorphiumAggregationIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/MorphiumBase.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/bulk/MorphiumBulkContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/MorphiumCache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/MorphiumCacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/MorphiumCacheJCacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumCollection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/MorphiumConfig.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:940: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:949: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:957: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:966: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:975: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:984: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:993: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:1002: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:1010: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:1018: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:1026: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/MorphiumConfig.CompressionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/MorphiumConfigResolver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumCursorAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumDriver.CompressionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumDriver.DriverStatsKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumDriverException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumDriverNetworkException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumDriverOperation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumId.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/MorphiumIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/MorphiumMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/MorphiumObjectMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/MorphiumReference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/MorphiumServer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/MorphiumServerCLI.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/MorphiumStorageAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/MorphiumStorageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/MorphiumStorageListener.UpdateTypes.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumTransactionContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/MorphiumTransactionContextImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/MorphiumTypeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/MorphiumWriter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/MorphiumWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/MorphiumWriterImpl.WT.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/Msg.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/Msg.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/MsgLock.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/MsgLock.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/MultiCollectionMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/MultiCollectionMessaging.java:1786: Fehler: @param-Name nicht gefunden -[INFO] * @param timoutInMs milliseconds to wait until listener is removed -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/MultiLineString.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/MultiPoint.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/MultiPolygon.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/MultiResultCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/NameProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/NetworkCallHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/NetworkCallHelper.ErrorCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/NoCache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/ObjectMapperImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/ObjectMappingSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpCompressed.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpDelete.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpGetMore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpInsert.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpKillCursors.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/OplogListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpMsg.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpQuery.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpReply.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/Point.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/Polygon.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/PooledDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/PooledDriver.ConnectionContainer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/PooledDriver.PingStats.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/PostLoad.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/PostRemove.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/PostStore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/PostUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/PreRemove.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/PreStore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/PreUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/Producer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Property.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/Property.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/PropertyEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/Query.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/Query.TextSearchLanguages.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/QueryHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/QueryIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/ReadAccessType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/ReadMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/ReadOnly.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/ReadPreference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/ReadPreferenceLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/ReadPreferenceType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Reference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/RemoveProcessTask.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/RenameCollectionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/ReplicaSetConf.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/ReplicaSetNode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/ReplicaSetStatus.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/ReplicasetStatusListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/ReplicastStatusCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/ReplicationCoordinator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/ReplicationManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/RSAEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/RunCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/RunCommand.Command.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/RunCommand.ErrorCode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/RunCommand.Response.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/RunCommandResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/SafetyLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/SaslAuthCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Sequence.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Sequence.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Sequence.SeqLock.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/SequenceGenerator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/Settings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/ShortMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/ShutdownCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/ShutdownListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/SingleBatchCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/SingleCollectionMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:128: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:139: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:147: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:155: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:172: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:189: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:202: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - processMultiple is unused -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:1835: Fehler: @param-Name nicht gefunden -[INFO] * @param timoutInMs - milliseconds to wait until listener is removed -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/SingleCollectionMessaging.AsyncMessageCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/SingleCollectionMessaging.MessageTimeoutException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/SingleCollectionMessaging.ProcessingQueueElement.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/SingleCollectionMessaging.SystemShutdownException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/SingleElementCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/SingleElementResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/SingleMongoConnectDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/SingleMongoConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/SingleMongoConnectionCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/SingleResultCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/SslHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/StatisticKeys.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Statistics.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/StatisticValue.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/StatusInfoListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/StatusInfoListener.InternalCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/StatusInfoListener.StatusInfoLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/StepDownCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/StoreMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/ThreadPoolSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/ThrowOnError.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/TimestampMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Transient.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/UpdateBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/UpdateMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/UseIfnull.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Utils.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/UtilsMap.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/UUIDRepresentation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/ValueEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/VoteRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/VoteResponse.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/WatchCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/WatchCommand.FullDocumentBeforeChangeEnum.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/WatchCommand.FullDocumentEnum.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/WatchCursorManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/WatchingCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/WatchingCacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/WireProtocolMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/WireProtocolMessage.OpCode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/WriteAccessType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/WriteBuffer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/WriteBuffer.STRATEGY.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/WriteConcern.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/WriteMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/WriterSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/WriterTask.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/WriteSafety.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/encryption/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/encryption/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/async/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/async/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/bulk/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/bulk/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/changestream/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/changestream/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/mongodb/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/mongodb/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/messaging/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/messaging/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/validation/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/validation/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/constant-values.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/serialized-form.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/AggregationIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/Group.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/Aggregator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/Aggregator.MergeActionWhenNotMatched.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/Aggregator.MergeActionWhenMatched.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/Aggregator.BucketGranularity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/Aggregator.GeoNearFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/AggregatorImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/MorphiumAggregationIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/Expr.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/Expr.ValueExpr.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/class-use/AESEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/class-use/RSAEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/class-use/DefaultEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/class-use/EncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/class-use/MongoEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/class-use/ValueEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/class-use/PropertyEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/MorphiumStorageAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Sequence.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Sequence.SeqLock.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Sequence.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/FilterExpression.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/MorphiumStorageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/MorphiumStorageListener.UpdateTypes.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/LazyDeReferencingProxy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/StatisticKeys.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Utils.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/DefaultNameProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/MorphiumBase.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/MorphiumAccessVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/CollectionInfo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/WriteAccessType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Statistics.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/MongoType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/AnnotationAndReflectionHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/DAO.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Collation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Collation.Strength.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Collation.MaxVariable.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Collation.CaseFirst.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Collation.Alternate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/UtilsMap.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/SequenceGenerator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/IndexDescription.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/NameProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/MorphiumReference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/StatisticValue.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/ReadAccessType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/MorphiumConfig.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/MorphiumConfig.CompressionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/ShutdownListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Morphium.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/ObjectMapperImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/MorphiumConfigResolver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/ThrowOnError.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/BinarySerializedObject.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/bulk/class-use/MorphiumBulkContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/WatchingCacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/WatchingCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/MessagingCacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/MessagingCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/CacheSyncVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/MorphiumCacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/AbstractCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/CacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/CacheHousekeeper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/MorphiumCache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/MorphiumCacheJCacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/MessagingCacheSyncAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/CacheListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CacheEventVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CacheManagerImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CacheEntry.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/class-use/HouseKeepingHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CacheImpl.CEvent.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CachingProviderImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/EncryptionSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/MessagingSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/MessagingSettings.RecipientCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/MessagingSettings.TopicCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/DriverSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/AuthSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/ConnectionSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/ClusterSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/ObjectMappingSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/CollectionCheckSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/CollectionCheckSettings.CappedCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/CollectionCheckSettings.IndexCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/ThreadPoolSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/Settings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/CacheSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/WriterSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/class-use/MongoBob.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/class-use/MongoJSScript.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/class-use/MongoMaxKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/class-use/UUIDRepresentation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/class-use/BsonDecoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/class-use/MongoTimestamp.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/class-use/MongoMinKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/class-use/BsonEncoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumCollection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/FunctionNotSupportedException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriver.CompressionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriver.DriverStatsKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/Doc.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriverNetworkException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/SingleElementCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriverException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumId.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/DriverTailableIterationCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/SingleBatchCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/ReadPreference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/ReadPreferenceType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/WriteConcern.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumCursorAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumTransactionContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriverOperation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/class-use/InsertBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/class-use/BulkRequestContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/class-use/UpdateBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/class-use/DeleteBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/class-use/BulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/SslHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/HelloResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/NetworkCallHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/NetworkCallHelper.ErrorCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/BulkContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/MorphiumTransactionContextImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/ConnectionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/SingleMongoConnectDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/PooledDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/PooledDriver.ConnectionContainer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/PooledDriver.PingStats.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/AtomicDecimal.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/DriverBase.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/MongoConnectionThread.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/SingleMongoConnectionCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/Host.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/MongoConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/SingleMongoConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/class-use/RunCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/class-use/RunCommand.ErrorCode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/class-use/RunCommand.Command.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/class-use/RunCommand.Response.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/mongodb/class-use/Maximums.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/ReadMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/CreateCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/CreateCommand.Granularity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/InsertMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/GenericCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/DropIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/FindAndModifyMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/MongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/HelloCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/ExplainCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/ExplainCommand.ExplainVerbosity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/GetMoreMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/DistinctMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/StepDownCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/CollStatsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/DbStatsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/KillCursorsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/AggregateMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/DropDatabaseMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/MapReduceCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/ListDatabasesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/CreateIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/UpdateMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/CommitTransactionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/SingleResultCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/AbortTransactionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/WatchCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/WatchCommand.FullDocumentEnum.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/WatchCommand.FullDocumentBeforeChangeEnum.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/ClearCollectionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/DropMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/ShutdownCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/MultiResultCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/RenameCollectionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/WriteMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/FindCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/CountMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/StoreMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/CurrentOpCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/AdminMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/ListCollectionsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/ListIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/ReplicastStatusCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/DeleteMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/class-use/RunCommandResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/class-use/SingleElementResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/class-use/CursorResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/class-use/ListResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/class-use/SaslAuthCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/class-use/CreateUserAdminCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/class-use/CreateRoleAdminCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/class-use/CreateRoleAdminCommand.Resource.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/class-use/CreateRoleAdminCommand.Privilege.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpMsg.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/WireProtocolMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/WireProtocolMessage.OpCode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpGetMore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpQuery.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpDelete.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpInsert.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpCompressed.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpReply.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpKillCursors.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/class-use/InMemTransactionContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/class-use/InMemoryDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/class-use/InMemoryDriver.MapReduceEmitter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/class-use/InMemDumpContainer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/class-use/InMemAggregator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/class-use/QueryHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/async/class-use/AsyncOperationType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/async/class-use/AsyncCallbackAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/async/class-use/AsyncOperationCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/ShortMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/MorphiumObjectMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/TimestampMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/AtomicBooleanMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/BigDecimalMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/BsonGeoMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/ByteMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/LocalTimeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/LocalDateMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/LocalDateTimeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/CharacterMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/BigIntegerTypeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/AtomicLongMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/AtomicIntegerMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/InstantMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/MorphiumTypeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/class-use/ReplicationManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/class-use/MorphiumServerCLI.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/class-use/ReplicationCoordinator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/class-use/MorphiumServer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/class-use/MongoWireProtocolEncoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/class-use/MongoCommandHandler.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/class-use/MongoWireProtocolDecoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/class-use/WatchCursorManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/class-use/ElectionNetworkClient.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/class-use/AppendEntriesResponse.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/class-use/VoteRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/class-use/ElectionConfig.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/class-use/AppendEntriesRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/class-use/ElectionState.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/class-use/ElectionManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/class-use/VoteResponse.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/messaging/class-use/MessagingOptimizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/messaging/class-use/MessagingCollectionInfo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/encryption/class-use/Encrypted.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/DefaultReadPreference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Index.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/UseIfnull.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Property.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Id.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Capped.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/SafetyLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/AdditionalData.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Driver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/LimitToFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Reference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/LastChange.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/ReadPreferenceLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Embedded.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Transient.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Collation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/IgnoreNullFromDB.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/CreationTime.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/WriteSafety.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Aliases.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/IgnoreFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Entity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Entity.ReadConcernLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Entity.ValidationLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Entity.ValidationAction.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Messaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/LastAccess.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/ReadOnly.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PostStore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PostRemove.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PostLoad.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PreStore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PostUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PreRemove.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/Lifecycle.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PreUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/class-use/WriteBuffer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/class-use/WriteBuffer.STRATEGY.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/class-use/Cache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/class-use/Cache.SyncCacheStrategy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/class-use/Cache.ClearStrategy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/class-use/NoCache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/class-use/AsyncWrites.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/class-use/MorphiumWriter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/class-use/WriterTask.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/class-use/AsyncWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/class-use/BufferedMorphiumWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/class-use/MorphiumWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/class-use/MorphiumWriterImpl.WT.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/class-use/OplogListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/class-use/ReplicasetStatusListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/class-use/ReplicaSetConf.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/class-use/ReplicaSetNode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/class-use/ConfNode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/class-use/ReplicaSetStatus.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/class-use/QueryIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/class-use/Property.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/class-use/MongoFieldImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/class-use/MorphiumIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/class-use/Query.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/class-use/Query.TextSearchLanguages.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/class-use/MongoField.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/class-use/FieldNames.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/class-use/Polygon.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/class-use/Geo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/class-use/MultiPoint.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/class-use/Point.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/class-use/MultiLineString.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/class-use/MultiPolygon.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/class-use/GeoType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/class-use/LineString.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/MessageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/Msg.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/Msg.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/MultiCollectionMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/MorphiumMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/MessagingRegistry.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/MsgLock.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/MsgLock.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/StatusInfoListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/StatusInfoListener.InternalCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/StatusInfoListener.StatusInfoLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/RemoveProcessTask.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/SingleCollectionMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/SingleCollectionMessaging.AsyncMessageCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/SingleCollectionMessaging.ProcessingQueueElement.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/SingleCollectionMessaging.SystemShutdownException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/SingleCollectionMessaging.MessageTimeoutException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/MessageRejectedException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/MessageRejectedException.RejectionHandler.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSTextMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSMapMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSObjectMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSDestination.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/Context.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSSession.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSConnectionFactory.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSTopic.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/Consumer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/Producer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSQueue.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSConnectionConsumer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSBytesMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/validation/class-use/JavaxValidationStorageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/changestream/class-use/ChangeStreamListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/changestream/class-use/ChangeStreamMonitor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/changestream/class-use/ChangeStreamEvent.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/encryption/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/async/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/bulk/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/changestream/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/mongodb/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/messaging/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/validation/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/overview-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/deprecated-list.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/index.html wird generiert... -[INFO] Index für alle Klassen wird erstellt... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/allclasses-index.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/allpackages-index.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/index-all.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/search.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/overview-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/help-doc.html wird generiert... -[INFO] 52 Fehler -[INFO] 100 Warnungen -[INFO] -[INFO] Command line was: /usr/bin/javadoc -J-Xmx2048m @options @packages -[INFO] -[INFO] Refer to the generated Javadoc files in '/Users/stephan/develop/morphium/target/checkout/target/apidocs' dir. -[INFO] -[INFO] org.apache.maven.reporting.MavenReportException: -[INFO] Exit code: 1 - Quelldateien werden geladen für Package de.caluga.morphium.aggregation... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.encryption... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.bulk... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.cache... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.cache.jcache... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.config... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.bson... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.bulk... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.wire... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.constants... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.mongodb... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.commands... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.commands.result... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.commands.auth... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.wireprotocol... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.driver.inmem... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.async... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.objectmapping... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.server... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.server.netty... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.server.election... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.server.messaging... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.annotations.encryption... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.annotations... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.annotations.lifecycle... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.annotations.caching... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.writer... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.replicaset... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.query... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.query.geospatial... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.messaging... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.messaging.jms... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.validation... -[INFO] Quelldateien werden geladen für Package de.caluga.morphium.changestream... -[INFO] Javadoc-Informationen werden erstellt... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/ObjectMapperImpl.java:22: Warnung: ReflectionFactory ist eine interne proprietäre API, die in einem zukünftigen Release entfernt werden kann -[INFO] import sun.reflect.ReflectionFactory; -[INFO] ^ -[INFO] Index für alle Packages und Klassen wird erstellt... -[INFO] Standard-Doclet-Version 21.0.9+10-LTS -[INFO] Baum für alle Packages und Klassen wird erstellt... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/aggregation/AggregationIterator.java:20: Warnung: kein @param für -[INFO] public class AggregationIterator implements MorphiumAggregationIterator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/aggregation/AggregationIterator.java:20: Warnung: kein @param für -[INFO] public class AggregationIterator implements MorphiumAggregationIterator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/aggregation/Group.java:19: Warnung: kein @param für -[INFO] public class Group { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/aggregation/Group.java:19: Warnung: kein @param für -[INFO] public class Group { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/aggregation/Aggregator.java:31: Warnung: kein @param für -[INFO] public interface Aggregator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/aggregation/Aggregator.java:31: Warnung: kein @param für -[INFO] public interface Aggregator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/aggregation/AggregatorImpl.java:28: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/aggregation/AggregatorImpl.java:31: Warnung: kein @param für -[INFO] public class AggregatorImpl implements Aggregator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/aggregation/AggregatorImpl.java:31: Warnung: kein @param für -[INFO] public class AggregatorImpl implements Aggregator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumStorageAdapter.java:15: Warnung: kein @param für -[INFO] public abstract class MorphiumStorageAdapter implements MorphiumStorageListener { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumStorageListener.java:13: Warnung: Keine Hauptbeschreibung -[INFO] * @author stephan -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumStorageListener.java:16: Warnung: kein @param für -[INFO] public interface MorphiumStorageListener { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/StatisticKeys.java:7: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/DAO.java:9: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/DAO.java:12: Warnung: kein @param für -[INFO] public abstract class DAO { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/ObjectMapperImpl.java:49: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/bulk/MorphiumBulkContext.java:24: Warnung: kein @param für -[INFO] public class MorphiumBulkContext { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:17: Warnung: kein @param für -[INFO] public abstract class AbstractCacheSynchronizer { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/CacheSyncVetoException.java:7: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/CacheSyncListener.java:7: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/MorphiumCacheJCacheImpl.java:28: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/MessagingCacheSyncAdapter.java:9: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/jcache/CacheEntry.java:10: Warnung: kein @param für -[INFO] public class CacheEntry { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/jcache/CacheImpl.java:26: Warnung: kein @param für -[INFO] public class CacheImpl implements Cache> { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/jcache/CacheImpl.java:26: Warnung: kein @param für -[INFO] public class CacheImpl implements Cache> { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/MorphiumDriverOperation.java:7: Warnung: kein @param für -[INFO] public interface MorphiumDriverOperation { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/wire/NetworkCallHelper.java:18: Warnung: kein @param für -[INFO] public class NetworkCallHelper { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/wire/DriverBase.java:25: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/wireprotocol/OpMsg.java:28: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/async/AsyncOperationCallback.java:14: Warnung: kein @param für -[INFO] public interface AsyncOperationCallback { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/objectmapping/MorphiumTypeMapper.java:10: Warnung: kein @param für -[INFO] public interface MorphiumTypeMapper { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/Index.java:15: Fehler: Überschrift in der falschen Reihenfolge verwendet:

, im Vergleich zur impliziten vorhergehenden Überschrift:

-[INFO] *

Single-Field Indexes

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/UseIfnull.java:13: Warnung: Keine Hauptbeschreibung -[INFO] * @Deprecated This annotation is deprecated. The default behavior has been changed to accept null values -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/UseIfnull.java:13: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated This annotation is deprecated. The default behavior has been changed to accept null values -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/UseIfnull.java:17: Fehler: Überschrift in der falschen Reihenfolge verwendet:

, im Vergleich zur impliziten vorhergehenden Überschrift:

-[INFO] *

Migration Guide:

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/IgnoreNullFromDB.java:15: Fehler: Überschrift in der falschen Reihenfolge verwendet:

, im Vergleich zur impliziten vorhergehenden Überschrift:

-[INFO] *

Behavior Summary:

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/CreationTime.java:16: Warnung: leeres -Tag -[INFO] * -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/CreationTime.java:16: Fehler: Element nicht geschlossen: code -[INFO] * -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/CreationTime.java:18: Fehler: unbekanntes Tag: CreationTime -[INFO] * @CreationTime class Test { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/CreationTime.java:19: Fehler: unbekanntes Tag: CreationTime -[INFO] * @CreationTime private long theTimestamp; -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/CreationTime.java:22: Fehler: unerwartetes Endtag: -[INFO] * -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/WriteSafety.java:16: Fehler: ungültiges Endtag:
-[INFO] *
-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/Aliases.java:15: Fehler: Element nicht geschlossen: code -[INFO] * -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/Aliases.java:18: Fehler: unbekanntes Tag: Aliases -[INFO] * @Aliases("alias","hugo") private String value; -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/Aliases.java:21: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/Aliases.java:22: Fehler: unerwartetes Endtag: -[INFO] * -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/Entity.java:16: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/writer/WriterTask.java:15: Warnung: kein @param für -[INFO] public interface WriterTask extends Runnable { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/writer/AsyncWriterImpl.java:15: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/query/QueryIterator.java:23: Warnung: kein @param für -[INFO] public class QueryIterator implements MorphiumIterator, Iterator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/query/MongoFieldImpl.java:29: Warnung: kein @param für -[INFO] public class MongoFieldImpl implements MongoField { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/query/MorphiumIterator.java:24: Warnung: kein @param für -[INFO] public interface MorphiumIterator extends Iterable, Iterator { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/query/Query.java:61: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/query/Query.java:64: Warnung: kein @param für -[INFO] public class Query implements Cloneable { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/query/MongoField.java:22: Warnung: kein @param für -[INFO] public interface MongoField { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/MessageListener.java:7: Warnung: leeres

-Tag -[INFO] *

-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/MessageListener.java:9: Warnung: kein @param für -[INFO] public interface MessageListener { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/Msg.java:19: Fehler: ungültiges Endtag:
-[INFO] *
-[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/AbortTransactionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java:67: Warnung: keine Beschreibung für @return -[INFO] * @return -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:9: Warnung: kein Kommentar -[INFO] public class AbortTransactionCommand extends AdminMongoCommand{ -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:14: Warnung: kein Kommentar -[INFO] public AbortTransactionCommand(MongoConnection d) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java:185: Warnung: kein Kommentar -[INFO] public Map asMap() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/SingleResultCommand.java:10: Warnung: kein Kommentar -[INFO] Map asMap(); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java:270: Warnung: kein Kommentar -[INFO] public abstract String getCommandName(); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:39: Warnung: kein Kommentar -[INFO] public UUID getLsid() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:48: Warnung: kein Kommentar -[INFO] public long getTxnNumber() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:30: Warnung: kein Kommentar -[INFO] public boolean isAutocommit() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:34: Warnung: kein Kommentar -[INFO] public AbortTransactionCommand setAutocommit(boolean autocommit) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:43: Warnung: kein Kommentar -[INFO] public AbortTransactionCommand setLsid(UUID lsid) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AbortTransactionCommand.java:52: Warnung: kein Kommentar -[INFO] public AbortTransactionCommand setTxnNumber(long txnNumber) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/AbstractCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:21: Warnung: kein Kommentar -[INFO] protected final Hashtable, Vector> listenerForType = new Hashtable<>(); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:20: Warnung: kein Kommentar -[INFO] protected final List listeners = Collections.synchronizedList(new ArrayList<>()); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:18: Warnung: kein Kommentar -[INFO] protected static final Logger log = LoggerFactory.getLogger(MessagingCacheSynchronizer.class); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:19: Warnung: kein Kommentar -[INFO] protected final Morphium morphium; -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:24: Warnung: kein Kommentar -[INFO] public AbstractCacheSynchronizer(Morphium morphium) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:37: Warnung: kein Kommentar -[INFO] public void addSyncListener(Class type, T cl) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:28: Warnung: kein Kommentar -[INFO] public void addSyncListener(T cl) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:66: Warnung: kein Kommentar -[INFO] public void firePostClearEvent(Class type) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:51: Warnung: kein Kommentar -[INFO] protected void firePreClearEvent(Class type) throws CacheSyncVetoException { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:43: Warnung: kein Kommentar -[INFO] public void removeSyncListener(Class type, T cl) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/cache/AbstractCacheSynchronizer.java:32: Warnung: kein Kommentar -[INFO] public void removeSyncListener(T cl) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/AdditionalData.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/annotations/AdditionalData.java:19: Warnung: kein Kommentar -[INFO] boolean readOnly() default true; -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/AdminMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AdminMongoCommand.java:9: Warnung: kein Kommentar -[INFO] public abstract class AdminMongoCommand extends MongoCommand implements SingleResultCommand { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AdminMongoCommand.java:10: Warnung: kein Kommentar -[INFO] public AdminMongoCommand(MongoConnection d) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/SingleResultCommand.java:8: Warnung: kein Kommentar -[INFO] Map execute() throws MorphiumDriverException; -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java:272: Warnung: kein Kommentar -[INFO] public int executeAsync() throws MorphiumDriverException { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/AESEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/encryption/AESEncryptionProvider.java:7: Warnung: kein Kommentar -[INFO] public class AESEncryptionProvider implements ValueEncryptionProvider { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/encryption/AESEncryptionProvider.java:11: Warnung: kein Kommentar -[INFO] public AESEncryptionProvider() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:14: Warnung: kein Kommentar -[INFO] byte[] decrypt(byte[] input); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:12: Warnung: kein Kommentar -[INFO] byte[] encrypt(byte[] input); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:10: Warnung: kein Kommentar -[INFO] void sedDecryptionKeyBase64(String key); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:8: Warnung: kein Kommentar -[INFO] void setDecryptionKey(byte[] key); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:4: Warnung: kein Kommentar -[INFO] void setEncryptionKey(byte[] key); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/encryption/ValueEncryptionProvider.java:6: Warnung: kein Kommentar -[INFO] void setEncryptionKeyBase64(String key); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/AggregateMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:15: Warnung: kein Kommentar -[INFO] public class AggregateMongoCommand extends ReadMongoCommand implements MultiResultCommand { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:29: Warnung: kein Kommentar -[INFO] public AggregateMongoCommand(MongoConnection d) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/MultiResultCommand.java:15: Warnung: kein Kommentar -[INFO] Map asMap(); -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:160: Warnung: kein Kommentar -[INFO] public Map explain(ExplainVerbosity verbosity) throws MorphiumDriverException{ -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/MongoCommand.java:108: Warnung: kein Kommentar -[INFO] public T fromMap(Map m) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:60: Warnung: kein Kommentar -[INFO] public Boolean getAllowDiskUse() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:33: Warnung: kein Kommentar -[INFO] public Integer getBatchSize() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:78: Warnung: kein Kommentar -[INFO] public Boolean getBypassDocumentValidation() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:96: Warnung: kein Kommentar -[INFO] public Map getCollation() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:132: Warnung: kein Kommentar -[INFO] public Map getCursor() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:51: Warnung: kein Kommentar -[INFO] public Boolean getExplain() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:105: Warnung: kein Kommentar -[INFO] public Object getHint() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:123: Warnung: kein Kommentar -[INFO] public Map getLet() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:69: Warnung: kein Kommentar -[INFO] public Integer getMaxWaitTime() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:42: Warnung: kein Kommentar -[INFO] public List> getPipeline() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:87: Warnung: kein Kommentar -[INFO] public Map getReadConcern() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:114: Warnung: kein Kommentar -[INFO] public Map getWriteConern() { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/driver/commands/AggregateMongoCommand.java:64: Warnung: kein Kommentar -[INFO] public AggregateMongoCommand setAllowDiskUse(Boolean allowDiskUse) { -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/AggregationIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/Aggregator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/Aggregator.BucketGranularity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/Aggregator.GeoNearFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/Aggregator.MergeActionWhenMatched.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/Aggregator.MergeActionWhenNotMatched.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/AggregatorImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Aliases.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/AnnotationAndReflectionHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/AppendEntriesRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/AppendEntriesResponse.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/async/AsyncCallbackAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/async/AsyncOperationCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/async/AsyncOperationType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/AsyncWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/AsyncWrites.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/AtomicBooleanMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/AtomicDecimal.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/AtomicIntegerMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/AtomicLongMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/AuthSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/BigDecimalMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/BigIntegerTypeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/BinarySerializedObject.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/BsonDecoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/BsonEncoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/BsonGeoMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/BufferedMorphiumWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/BulkContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/BulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/BulkRequestContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/ByteMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/Cache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/Cache.ClearStrategy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/Cache.SyncCacheStrategy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/CacheEntry.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/CacheEventVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/CacheHousekeeper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/CacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/CacheImpl.CEvent.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/CacheListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/CacheManagerImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/CacheSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/CacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/CacheSyncVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/CachingProviderImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Capped.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/changestream/ChangeStreamEvent.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/changestream/ChangeStreamListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/changestream/ChangeStreamMonitor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/CharacterMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/ClearCollectionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/ClusterSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Collation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Collation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Collation.Alternate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Collation.CaseFirst.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Collation.MaxVariable.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Collation.Strength.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/CollectionCheckSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/CollectionCheckSettings.CappedCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/CollectionCheckSettings.IndexCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/CollectionInfo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/CollStatsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/CommitTransactionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/ConfNode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/ConnectionSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/ConnectionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/Consumer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/Context.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/CountMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/CreateCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/CreateCommand.Granularity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/CreateIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/CreateRoleAdminCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/CreateRoleAdminCommand.Privilege.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/CreateRoleAdminCommand.Resource.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/CreateUserAdminCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/CreationTime.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/CurrentOpCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/CursorResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/DAO.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/DbStatsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/DefaultEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/DefaultNameProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/DefaultReadPreference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/DeleteBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/DeleteMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/DistinctMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/Doc.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Driver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/DriverBase.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/DriverSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/DriverTailableIterationCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/DropDatabaseMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/DropIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/DropMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/ElectionConfig.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/ElectionManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/ElectionNetworkClient.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/ElectionState.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Embedded.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/encryption/Encrypted.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/EncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/EncryptionSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Entity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Entity.ReadConcernLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Entity.ValidationAction.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Entity.ValidationLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/ExplainCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/ExplainCommand.ExplainVerbosity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/Expr.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/Expr.ValueExpr.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/FieldNames.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/FilterExpression.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/FindAndModifyMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/FindCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/FunctionNotSupportedException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/GenericCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/Geo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/GeoType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/GetMoreMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/Group.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/HelloCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/HelloResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/Host.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/HouseKeepingHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Id.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/IgnoreFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/IgnoreNullFromDB.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Index.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/IndexDescription.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/InMemAggregator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/InMemDumpContainer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/InMemoryDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/InMemoryDriver.MapReduceEmitter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/InMemTransactionContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/InsertBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/InsertMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/InstantMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/validation/JavaxValidationStorageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSBytesMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSConnectionConsumer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSConnectionFactory.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSDestination.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSMapMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSObjectMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSQueue.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSSession.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSTextMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/JMSTopic.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/KillCursorsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/LastAccess.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/LastChange.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/LazyDeReferencingProxy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/Lifecycle.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/LimitToFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/LineString.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/ListCollectionsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/ListDatabasesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/ListIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/ListResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/LocalDateMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/LocalDateTimeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/LocalTimeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/MapReduceCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/mongodb/Maximums.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/MessageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/MessageRejectedException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/MessageRejectedException.RejectionHandler.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Messaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/MessagingCacheSyncAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/MessagingCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/MessagingCacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/messaging/MessagingCollectionInfo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/messaging/MessagingOptimizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/MessagingRegistry.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/MessagingSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/MessagingSettings.RecipientCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/MessagingSettings.TopicCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/MongoBob.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/MongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/MongoCommandHandler.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/MongoConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/MongoConnectionThread.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/MongoEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/MongoField.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/MongoFieldImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/MongoJSScript.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/MongoMaxKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/MongoMinKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/MongoTimestamp.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/MongoType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/MongoWireProtocolDecoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/MongoWireProtocolEncoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Morphium.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:794: Fehler: Nicht abgeschlossenes Inlinetag -[INFO] * Please use {@link Morphium#setInEntity(Object, String, Map) -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1015: Fehler: Nicht wohlgeformte HTML -[INFO] * unmarshalled, you might get MongoMaps -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1140: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1153: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1164: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1243: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1272: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1283: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1296: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1459: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1470: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1481: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumBase.java:1493: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/Morphium.java:810: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use {Morphium{@link #unsetInEntity(Object, String, String, AsyncOperationCallback)} instead. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/Morphium.java:1073: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/Morphium.java:1094: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated There is a newer implementation. -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/Morphium.java:1169: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use {@link Morphium#remove(List, String)} -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/Morphium.java:1178: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use {@link Morphium#remove(List, String, AsyncOperationCallback)} -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/Morphium.java:1672: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated - for read access use {@link Query} instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/MorphiumAccessVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/MorphiumAggregationIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/MorphiumBase.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/bulk/MorphiumBulkContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/MorphiumCache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/MorphiumCacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/MorphiumCacheJCacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumCollection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/MorphiumConfig.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:940: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:949: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:957: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:966: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:975: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:984: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:993: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:1002: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:1010: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:1018: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/MorphiumConfig.java:1026: Fehler: unbekanntes Tag: Deprecated -[INFO] @Deprecated use getConnectionSettings().METHOD -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/MorphiumConfig.CompressionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/MorphiumConfigResolver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumCursorAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumDriver.CompressionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumDriver.DriverStatsKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumDriverException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumDriverNetworkException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumDriverOperation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumId.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/MorphiumIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/MorphiumMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/MorphiumObjectMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/MorphiumReference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/MorphiumServer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/MorphiumServerCLI.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/MorphiumStorageAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/MorphiumStorageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/MorphiumStorageListener.UpdateTypes.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/MorphiumTransactionContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/MorphiumTransactionContextImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/MorphiumTypeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/MorphiumWriter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/MorphiumWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/MorphiumWriterImpl.WT.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/Msg.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/Msg.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/MsgLock.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/MsgLock.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/MultiCollectionMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/MultiCollectionMessaging.java:1786: Fehler: @param-Name nicht gefunden -[INFO] * @param timoutInMs milliseconds to wait until listener is removed -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/MultiLineString.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/MultiPoint.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/MultiPolygon.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/MultiResultCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/NameProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/NetworkCallHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/NetworkCallHelper.ErrorCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/NoCache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/ObjectMapperImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/ObjectMappingSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpCompressed.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpDelete.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpGetMore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpInsert.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpKillCursors.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/OplogListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpMsg.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpQuery.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpReply.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/OpUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/Point.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/Polygon.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/PooledDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/PooledDriver.ConnectionContainer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/PooledDriver.PingStats.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/PostLoad.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/PostRemove.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/PostStore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/PostUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/PreRemove.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/PreStore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/PreUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/Producer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Property.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/Property.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/PropertyEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/Query.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/Query.TextSearchLanguages.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/QueryHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/QueryIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/ReadAccessType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/ReadMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/ReadOnly.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/ReadPreference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/ReadPreferenceLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/ReadPreferenceType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Reference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/RemoveProcessTask.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/RenameCollectionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/ReplicaSetConf.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/ReplicaSetNode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/ReplicaSetStatus.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/ReplicasetStatusListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/ReplicastStatusCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/ReplicationCoordinator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/ReplicationManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/RSAEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/RunCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/RunCommand.Command.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/RunCommand.ErrorCode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/RunCommand.Response.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/RunCommandResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/SafetyLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/SaslAuthCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Sequence.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Sequence.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Sequence.SeqLock.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/SequenceGenerator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/Settings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/ShortMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/ShutdownCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/ShutdownListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/SingleBatchCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/SingleCollectionMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:128: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:139: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:147: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:155: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:172: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:189: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - use morphium.createMessaging() instead -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:202: Fehler: unbekanntes Tag: Deprecated -[INFO] * @Deprecated - processMultiple is unused -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java:1835: Fehler: @param-Name nicht gefunden -[INFO] * @param timoutInMs - milliseconds to wait until listener is removed -[INFO] ^ -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/SingleCollectionMessaging.AsyncMessageCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/SingleCollectionMessaging.MessageTimeoutException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/SingleCollectionMessaging.ProcessingQueueElement.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/SingleCollectionMessaging.SystemShutdownException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/SingleElementCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/SingleElementResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/SingleMongoConnectDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/SingleMongoConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/SingleMongoConnectionCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/SingleResultCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/SslHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/StatisticKeys.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Statistics.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/StatisticValue.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/StatusInfoListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/StatusInfoListener.InternalCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/StatusInfoListener.StatusInfoLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/StepDownCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/StoreMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/ThreadPoolSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/ThrowOnError.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/TimestampMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/Transient.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/UpdateBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/UpdateMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/UseIfnull.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/Utils.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/UtilsMap.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/UUIDRepresentation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/ValueEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/VoteRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/VoteResponse.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/WatchCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/WatchCommand.FullDocumentBeforeChangeEnum.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/WatchCommand.FullDocumentEnum.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/WatchCursorManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/WatchingCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/WatchingCacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/WireProtocolMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/WireProtocolMessage.OpCode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/WriteAccessType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/WriteBuffer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/WriteBuffer.STRATEGY.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/WriteConcern.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/WriteMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/WriterSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/WriterTask.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/WriteSafety.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/encryption/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/encryption/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/async/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/async/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/bulk/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/bulk/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/changestream/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/changestream/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/mongodb/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/mongodb/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/messaging/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/messaging/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/validation/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/validation/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/package-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/package-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/constant-values.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/serialized-form.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/AggregationIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/Group.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/Aggregator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/Aggregator.MergeActionWhenNotMatched.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/Aggregator.MergeActionWhenMatched.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/Aggregator.BucketGranularity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/Aggregator.GeoNearFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/AggregatorImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/MorphiumAggregationIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/Expr.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/class-use/Expr.ValueExpr.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/class-use/AESEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/class-use/RSAEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/class-use/DefaultEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/class-use/EncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/class-use/MongoEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/class-use/ValueEncryptionProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/class-use/PropertyEncryptionKeyProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/MorphiumStorageAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Sequence.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Sequence.SeqLock.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Sequence.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/FilterExpression.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/MorphiumStorageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/MorphiumStorageListener.UpdateTypes.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/LazyDeReferencingProxy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/StatisticKeys.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Utils.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/DefaultNameProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/MorphiumBase.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/MorphiumAccessVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/CollectionInfo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/WriteAccessType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Statistics.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/MongoType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/AnnotationAndReflectionHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/DAO.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Collation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Collation.Strength.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Collation.MaxVariable.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Collation.CaseFirst.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Collation.Alternate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/UtilsMap.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/SequenceGenerator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/IndexDescription.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/NameProvider.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/MorphiumReference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/StatisticValue.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/ReadAccessType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/MorphiumConfig.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/MorphiumConfig.CompressionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/ShutdownListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/Morphium.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/ObjectMapperImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/MorphiumConfigResolver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/ThrowOnError.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/class-use/BinarySerializedObject.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/bulk/class-use/MorphiumBulkContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/WatchingCacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/WatchingCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/MessagingCacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/MessagingCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/CacheSyncVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/MorphiumCacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/AbstractCacheSynchronizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/CacheSyncListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/CacheHousekeeper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/MorphiumCache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/MorphiumCacheJCacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/MessagingCacheSyncAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/class-use/CacheListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CacheEventVetoException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CacheManagerImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CacheEntry.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/class-use/HouseKeepingHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CacheImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CacheImpl.CEvent.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/class-use/CachingProviderImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/EncryptionSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/MessagingSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/MessagingSettings.RecipientCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/MessagingSettings.TopicCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/DriverSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/AuthSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/ConnectionSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/ClusterSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/ObjectMappingSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/CollectionCheckSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/CollectionCheckSettings.CappedCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/CollectionCheckSettings.IndexCheck.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/ThreadPoolSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/Settings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/CacheSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/class-use/WriterSettings.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/class-use/MongoBob.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/class-use/MongoJSScript.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/class-use/MongoMaxKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/class-use/UUIDRepresentation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/class-use/BsonDecoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/class-use/MongoTimestamp.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/class-use/MongoMinKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/class-use/BsonEncoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumCollection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/FunctionNotSupportedException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriver.CompressionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriver.DriverStatsKey.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/Doc.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriverNetworkException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/SingleElementCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriverException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumId.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/DriverTailableIterationCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/SingleBatchCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/ReadPreference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/ReadPreferenceType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/WriteConcern.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumCursorAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumTransactionContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/class-use/MorphiumDriverOperation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/class-use/InsertBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/class-use/BulkRequestContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/class-use/UpdateBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/class-use/DeleteBulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/class-use/BulkRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/SslHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/HelloResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/NetworkCallHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/NetworkCallHelper.ErrorCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/BulkContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/MorphiumTransactionContextImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/ConnectionType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/SingleMongoConnectDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/PooledDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/PooledDriver.ConnectionContainer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/PooledDriver.PingStats.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/AtomicDecimal.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/DriverBase.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/MongoConnectionThread.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/SingleMongoConnectionCursor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/Host.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/MongoConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/class-use/SingleMongoConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/class-use/RunCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/class-use/RunCommand.ErrorCode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/class-use/RunCommand.Command.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/class-use/RunCommand.Response.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/mongodb/class-use/Maximums.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/ReadMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/CreateCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/CreateCommand.Granularity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/InsertMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/GenericCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/DropIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/FindAndModifyMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/MongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/HelloCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/ExplainCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/ExplainCommand.ExplainVerbosity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/GetMoreMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/DistinctMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/StepDownCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/CollStatsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/DbStatsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/KillCursorsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/AggregateMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/DropDatabaseMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/MapReduceCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/ListDatabasesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/CreateIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/UpdateMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/CommitTransactionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/SingleResultCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/AbortTransactionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/WatchCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/WatchCommand.FullDocumentEnum.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/WatchCommand.FullDocumentBeforeChangeEnum.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/ClearCollectionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/DropMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/ShutdownCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/MultiResultCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/RenameCollectionCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/WriteMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/FindCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/CountMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/StoreMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/CurrentOpCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/AdminMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/ListCollectionsCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/ListIndexesCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/ReplicastStatusCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/class-use/DeleteMongoCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/class-use/RunCommandResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/class-use/SingleElementResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/class-use/CursorResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/class-use/ListResult.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/class-use/SaslAuthCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/class-use/CreateUserAdminCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/class-use/CreateRoleAdminCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/class-use/CreateRoleAdminCommand.Resource.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/class-use/CreateRoleAdminCommand.Privilege.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpMsg.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/WireProtocolMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/WireProtocolMessage.OpCode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpGetMore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpQuery.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpDelete.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpInsert.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpCompressed.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpReply.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/class-use/OpKillCursors.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/class-use/InMemTransactionContext.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/class-use/InMemoryDriver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/class-use/InMemoryDriver.MapReduceEmitter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/class-use/InMemDumpContainer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/class-use/InMemAggregator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/class-use/QueryHelper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/async/class-use/AsyncOperationType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/async/class-use/AsyncCallbackAdapter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/async/class-use/AsyncOperationCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/ShortMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/MorphiumObjectMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/TimestampMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/AtomicBooleanMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/BigDecimalMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/BsonGeoMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/ByteMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/LocalTimeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/LocalDateMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/LocalDateTimeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/CharacterMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/BigIntegerTypeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/AtomicLongMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/AtomicIntegerMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/InstantMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/class-use/MorphiumTypeMapper.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/class-use/ReplicationManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/class-use/MorphiumServerCLI.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/class-use/ReplicationCoordinator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/class-use/MorphiumServer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/class-use/MongoWireProtocolEncoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/class-use/MongoCommandHandler.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/class-use/MongoWireProtocolDecoder.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/class-use/WatchCursorManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/class-use/ElectionNetworkClient.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/class-use/AppendEntriesResponse.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/class-use/VoteRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/class-use/ElectionConfig.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/class-use/AppendEntriesRequest.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/class-use/ElectionState.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/class-use/ElectionManager.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/class-use/VoteResponse.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/messaging/class-use/MessagingOptimizer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/messaging/class-use/MessagingCollectionInfo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/encryption/class-use/Encrypted.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/DefaultReadPreference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Index.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/UseIfnull.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Property.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Id.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Capped.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/SafetyLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/AdditionalData.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Driver.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/LimitToFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Reference.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/LastChange.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/ReadPreferenceLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Embedded.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Transient.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Collation.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/IgnoreNullFromDB.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/CreationTime.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/WriteSafety.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Aliases.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/IgnoreFields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Entity.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Entity.ReadConcernLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Entity.ValidationLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Entity.ValidationAction.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/Messaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/LastAccess.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/class-use/ReadOnly.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PostStore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PostRemove.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PostLoad.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PreStore.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PostUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PreRemove.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/Lifecycle.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/class-use/PreUpdate.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/class-use/WriteBuffer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/class-use/WriteBuffer.STRATEGY.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/class-use/Cache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/class-use/Cache.SyncCacheStrategy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/class-use/Cache.ClearStrategy.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/class-use/NoCache.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/class-use/AsyncWrites.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/class-use/MorphiumWriter.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/class-use/WriterTask.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/class-use/AsyncWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/class-use/BufferedMorphiumWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/class-use/MorphiumWriterImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/class-use/MorphiumWriterImpl.WT.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/class-use/OplogListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/class-use/ReplicasetStatusListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/class-use/ReplicaSetConf.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/class-use/ReplicaSetNode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/class-use/ConfNode.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/class-use/ReplicaSetStatus.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/class-use/QueryIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/class-use/Property.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/class-use/MongoFieldImpl.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/class-use/MorphiumIterator.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/class-use/Query.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/class-use/Query.TextSearchLanguages.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/class-use/MongoField.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/class-use/FieldNames.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/class-use/Polygon.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/class-use/Geo.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/class-use/MultiPoint.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/class-use/Point.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/class-use/MultiLineString.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/class-use/MultiPolygon.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/class-use/GeoType.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/class-use/LineString.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/MessageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/Msg.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/Msg.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/MultiCollectionMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/MorphiumMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/MessagingRegistry.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/MsgLock.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/MsgLock.Fields.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/StatusInfoListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/StatusInfoListener.InternalCommand.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/StatusInfoListener.StatusInfoLevel.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/RemoveProcessTask.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/SingleCollectionMessaging.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/SingleCollectionMessaging.AsyncMessageCallback.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/SingleCollectionMessaging.ProcessingQueueElement.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/SingleCollectionMessaging.SystemShutdownException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/SingleCollectionMessaging.MessageTimeoutException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/MessageRejectedException.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/class-use/MessageRejectedException.RejectionHandler.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSConnection.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSTextMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSMapMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSObjectMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSDestination.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/Context.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSSession.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSConnectionFactory.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSTopic.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/Consumer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/Producer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSQueue.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSConnectionConsumer.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/class-use/JMSBytesMessage.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/validation/class-use/JavaxValidationStorageListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/changestream/class-use/ChangeStreamListener.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/changestream/class-use/ChangeStreamMonitor.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/changestream/class-use/ChangeStreamEvent.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/aggregation/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/caching/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/encryption/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/annotations/lifecycle/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/async/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/bulk/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/cache/jcache/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/changestream/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/config/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bson/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/bulk/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/auth/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/commands/result/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/constants/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/inmem/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/mongodb/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wire/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/driver/wireprotocol/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/encryption/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/messaging/jms/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/objectmapping/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/query/geospatial/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/replicaset/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/election/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/messaging/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/server/netty/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/validation/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/de/caluga/morphium/writer/package-use.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/overview-tree.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/deprecated-list.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/index.html wird generiert... -[INFO] Index für alle Klassen wird erstellt... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/allclasses-index.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/allpackages-index.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/index-all.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/search.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/overview-summary.html wird generiert... -[INFO] /Users/stephan/develop/morphium/target/checkout/target/apidocs/help-doc.html wird generiert... -[INFO] 52 Fehler -[INFO] 100 Warnungen -[INFO] -[INFO] Command line was: /usr/bin/javadoc -J-Xmx2048m @options @packages -[INFO] -[INFO] Refer to the generated Javadoc files in '/Users/stephan/develop/morphium/target/checkout/target/apidocs' dir. -[INFO] -[INFO] at org.apache.maven.plugins.javadoc.AbstractJavadocMojo.doExecuteJavadocCommandLine (AbstractJavadocMojo.java:6092) -[INFO] at org.apache.maven.plugins.javadoc.AbstractJavadocMojo.executeJavadocCommandLine (AbstractJavadocMojo.java:5968) -[INFO] at org.apache.maven.plugins.javadoc.AbstractJavadocMojo.executeReport (AbstractJavadocMojo.java:2277) -[INFO] at org.apache.maven.plugins.javadoc.JavadocJar.doExecute (JavadocJar.java:189) -[INFO] at org.apache.maven.plugins.javadoc.AbstractJavadocMojo.execute (AbstractJavadocMojo.java:2034) -[INFO] at org.apache.maven.plugin.DefaultBuildPluginManager.executeMojo (DefaultBuildPluginManager.java:126) -[INFO] at org.apache.maven.lifecycle.internal.MojoExecutor.doExecute2 (MojoExecutor.java:328) -[INFO] at org.apache.maven.lifecycle.internal.MojoExecutor.doExecute (MojoExecutor.java:316) -[INFO] at org.apache.maven.lifecycle.internal.MojoExecutor.execute (MojoExecutor.java:212) -[INFO] at org.apache.maven.lifecycle.internal.MojoExecutor.execute (MojoExecutor.java:174) -[INFO] at org.apache.maven.lifecycle.internal.MojoExecutor.access$000 (MojoExecutor.java:75) -[INFO] at org.apache.maven.lifecycle.internal.MojoExecutor$1.run (MojoExecutor.java:162) -[INFO] at org.apache.maven.plugin.DefaultMojosExecutionStrategy.execute (DefaultMojosExecutionStrategy.java:39) -[INFO] at org.apache.maven.lifecycle.internal.MojoExecutor.execute (MojoExecutor.java:159) -[INFO] at org.apache.maven.lifecycle.internal.LifecycleModuleBuilder.buildProject (LifecycleModuleBuilder.java:105) -[INFO] at org.apache.maven.lifecycle.internal.LifecycleModuleBuilder.buildProject (LifecycleModuleBuilder.java:73) -[INFO] at org.apache.maven.lifecycle.internal.builder.singlethreaded.SingleThreadedBuilder.build (SingleThreadedBuilder.java:53) -[INFO] at org.apache.maven.lifecycle.internal.LifecycleStarter.execute (LifecycleStarter.java:118) -[INFO] at org.apache.maven.DefaultMaven.doExecute (DefaultMaven.java:261) -[INFO] at org.apache.maven.DefaultMaven.doExecute (DefaultMaven.java:173) -[INFO] at org.apache.maven.DefaultMaven.execute (DefaultMaven.java:101) -[INFO] at org.apache.maven.cli.MavenCli.execute (MavenCli.java:919) -[INFO] at org.apache.maven.cli.MavenCli.doMain (MavenCli.java:285) -[INFO] at org.apache.maven.cli.MavenCli.main (MavenCli.java:207) -[INFO] at jdk.internal.reflect.DirectMethodHandleAccessor.invoke (DirectMethodHandleAccessor.java:103) -[INFO] at java.lang.reflect.Method.invoke (Method.java:580) -[INFO] at org.codehaus.plexus.classworlds.launcher.Launcher.launchEnhanced (Launcher.java:255) -[INFO] at org.codehaus.plexus.classworlds.launcher.Launcher.launch (Launcher.java:201) -[INFO] at org.codehaus.plexus.classworlds.launcher.Launcher.mainWithExitCode (Launcher.java:361) -[INFO] at org.codehaus.plexus.classworlds.launcher.Launcher.main (Launcher.java:314) -[INFO] [INFO] Building jar: /Users/stephan/develop/morphium/target/checkout/target/morphium-6.1.6-javadoc.jar -[INFO] [INFO] -[INFO] [INFO] --- assembly:3.7.1:single (make-assembly) @ morphium --- -[INFO] [INFO] Reading assembly descriptor: src/main/assembly/server-cli.xml -[INFO] [INFO] Building jar: /Users/stephan/develop/morphium/target/checkout/target/morphium-6.1.6-server-cli.jar -[INFO] [INFO] -[INFO] [INFO] --- install:3.1.2:install (default-install) @ morphium --- -[INFO] [INFO] Installing /Users/stephan/develop/morphium/target/checkout/pom.xml to /Users/stephan/.m2/repository/de/caluga/morphium/6.1.6/morphium-6.1.6.pom -[INFO] [INFO] Installing /Users/stephan/develop/morphium/target/checkout/target/morphium-6.1.6.jar to /Users/stephan/.m2/repository/de/caluga/morphium/6.1.6/morphium-6.1.6.jar -[INFO] [INFO] Installing /Users/stephan/develop/morphium/target/checkout/target/morphium-6.1.6-sources.jar to /Users/stephan/.m2/repository/de/caluga/morphium/6.1.6/morphium-6.1.6-sources.jar -[INFO] [INFO] Installing /Users/stephan/develop/morphium/target/checkout/target/morphium-6.1.6-javadoc.jar to /Users/stephan/.m2/repository/de/caluga/morphium/6.1.6/morphium-6.1.6-javadoc.jar -[INFO] [INFO] Installing /Users/stephan/develop/morphium/target/checkout/target/morphium-6.1.6-server-cli.jar to /Users/stephan/.m2/repository/de/caluga/morphium/6.1.6/morphium-6.1.6-server-cli.jar -[INFO] [INFO] -[INFO] [INFO] --- deploy:3.1.2:deploy (default-deploy) @ morphium --- -[INFO] [INFO] ------------------------------------------------------------------------ -[INFO] [INFO] BUILD FAILURE -[INFO] [INFO] ------------------------------------------------------------------------ -[INFO] [INFO] Total time: 13.170 s -[INFO] [INFO] Finished at: 2026-01-28T09:28:42+01:00 -[INFO] [INFO] ------------------------------------------------------------------------ -[INFO] [ERROR] Failed to execute goal org.apache.maven.plugins:maven-deploy-plugin:3.1.2:deploy (default-deploy) on project morphium: Deployment failed: repository element was not specified in the POM inside distributionManagement element or in -DaltDeploymentRepository=id::url parameter -> [Help 1] -[INFO] [ERROR] -[INFO] [ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch. -[INFO] [ERROR] Re-run Maven using the -X switch to enable full debug logging. -[INFO] [ERROR] -[INFO] [ERROR] For more information about the errors and possible solutions, please read the following articles: -[INFO] [ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoExecutionException -[INFO] ------------------------------------------------------------------------ -[INFO] BUILD FAILURE -[INFO] ------------------------------------------------------------------------ -[INFO] Total time: 17.783 s -[INFO] Finished at: 2026-01-28T09:28:42+01:00 -[INFO] ------------------------------------------------------------------------ -[ERROR] Failed to execute goal org.apache.maven.plugins:maven-release-plugin:2.5.3:perform (default-cli) on project morphium: Maven execution failed, exit code: '1' -> [Help 1] -[ERROR] -[ERROR] To see the full stack trace of the errors, re-run Maven with the -e switch. -[ERROR] Re-run Maven using the -X switch to enable full debug logging. -[ERROR] -[ERROR] For more information about the errors and possible solutions, please read the following articles: -[ERROR] [Help 1] http://cwiki.apache.org/confluence/display/MAVEN/MojoExecutionException diff --git a/mkdocs.yml b/mkdocs.yml index 7bf1c8047..97a9fe32e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -13,18 +13,20 @@ use_directory_urls: true theme: name: material + logo: assets/brand/morphium-mark-header.svg + favicon: assets/brand/morphium-mark.svg palette: # Light mode - scheme: default - primary: indigo - accent: indigo + primary: deep purple + accent: deep purple toggle: icon: material/brightness-7 name: Switch to dark mode # Dark mode - scheme: slate - primary: indigo - accent: indigo + primary: deep purple + accent: deep purple toggle: icon: material/brightness-4 name: Switch to light mode @@ -39,6 +41,9 @@ theme: - content.tabs.link - toc.follow +extra_css: + - assets/extra.css + plugins: - search - tags @@ -60,6 +65,15 @@ markdown_extensions: permalink: true title: On this page +# Internal design/planning documents - not part of the published site at all. +exclude_docs: | + superpowers/ + +# Historical release notes are kept as plain files, deliberately not part of the nav +# (CHANGELOG.md in the repo root is the maintained changelog). +not_in_nav: | + releases/* + nav: - Home: index.md - Getting Started: @@ -73,6 +87,8 @@ nav: - Test Runner: test-runner.md - InMemory Driver: inmemory-driver.md - Quick Reference: howtos/inmemory-driver.md + - Wire Proxy (Fault Injection): wire-proxy.md + - Developer Testing Guide: developer-testing-guide.md - PoppyDB: - Overview: poppydb.md - Production Deployment Playbook: howtos/poppydb-deployment.md @@ -85,14 +101,17 @@ nav: - Caching Examples: howtos/caching-examples.md - Cache Patterns: howtos/cache-patterns.md - Field Name Mapping: howtos/field-names.md + - Optimistic Locking: howtos/optimistic-locking.md + - References & Relationships: howtos/references-and-relationships.md - Core Features: - Messaging System: messaging.md - Messaging Implementations: howtos/messaging-implementations.md - SSL/TLS Connections: ssl-tls.md - Developer Guide: developer-guide.md - Extensions: - # Placeholder: Quarkus- und Spring-Boot-Integrationsseiten folgen in späteren Wellen (M4, M5). - Jakarta Data: jakarta-data.md + - Quarkus Extension: quarkus-extension.md + - Spring Boot: spring-boot.md - Reference: - API Reference: api-reference.md - Configuration: configuration-reference.md diff --git a/morphium-core/pom.xml b/morphium-core/pom.xml index b886cdc07..053aa62d0 100644 --- a/morphium-core/pom.xml +++ b/morphium-core/pom.xml @@ -4,7 +4,7 @@ de.caluga morphium-parent - 6.3.0-SNAPSHOT + 6.3.2-SNAPSHOT morphium jar diff --git a/morphium-core/src/main/java/de/caluga/morphium/AnnotationAndReflectionHelper.java b/morphium-core/src/main/java/de/caluga/morphium/AnnotationAndReflectionHelper.java index 022fa24a9..3255552c5 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/AnnotationAndReflectionHelper.java +++ b/morphium-core/src/main/java/de/caluga/morphium/AnnotationAndReflectionHelper.java @@ -45,6 +45,15 @@ public class AnnotationAndReflectionHelper { private static volatile ConcurrentHashMap classNameByType; private static volatile Map preRegisteredTypeIds; private Map fieldCache; + /** + * typeId (or FQCN) -> resolved Class. {@link #getClassForTypeId(String)} is called for + * every embedded object carrying a {@code class_name} attribute during deserialization, + * so the {@code Class.forName()} lookup must not run per call. Deliberately an instance + * (not static) cache: helper instances are tied to one Morphium instance/classloader, + * so hot-reload scenarios (Quarkus dev mode) get a fresh cache with the new helper. + * Only successful lookups are cached — a ClassNotFoundException still propagates per call. + */ + private final Map> typeIdClassCache = new ConcurrentHashMap<>(); private Map> fieldAnnotationListCache; private Map, Map < Class, Method >> lifeCycleMethods; private Map < Class, Boolean > hasAdditionalData; @@ -180,11 +189,16 @@ public String getTypeIdForClass(Class cls) { } public Class getClassForTypeId(String typeId) throws ClassNotFoundException { - if (classNameByType.containsKey(typeId)) { - return classForName(classNameByType.get(typeId)); + Class cached = typeIdClassCache.get(typeId); + + if (cached != null) { + return cached; } - return classForName(typeId); + String className = classNameByType.get(typeId); + Class cls = classForName(className != null ? className : typeId); + typeIdClassCache.put(typeId, cls); + return cls; } public boolean isAnnotationPresentInHierarchy(final Class aClass, final Class annotationClass) { diff --git a/morphium-core/src/main/java/de/caluga/morphium/IndexDescription.java b/morphium-core/src/main/java/de/caluga/morphium/IndexDescription.java index 672739c3f..146cbe7c8 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/IndexDescription.java +++ b/morphium-core/src/main/java/de/caluga/morphium/IndexDescription.java @@ -45,10 +45,23 @@ public static IndexDescription fromMap(Map incoming) { @SuppressWarnings("unchecked") Map keymap = (Map) incoming.get("key"); for (var k : keymap.keySet()) { + // MongoDB's own naming convention is "_" per key, joined by + // "_" between entries -- there is no separator after the LAST entry. Appending + // "_" unconditionally after every entry (as this used to do) produces a + // trailing underscore ("campaignNumber_1_" instead of "campaignNumber_1"), which + // silently breaks index creation on any database where an index on the same + // field already exists under the correct name: MongoDB rejects the mismatched + // name with "Error 85 - Index already exists with a different name", Morphium + // only logs that as a warning, and the index (with any unique constraint) is + // never created. This bug predates 6.3.0 -- it is present unchanged as far back + // as the v6.2.5 tag -- so it is a plain bugfix, not a behaviour change requiring + // a migration path. + if (sb.length() > 0) { + sb.append("_"); + } sb.append(k); sb.append("_"); sb.append(keymap.get(k).toString()); - sb.append("_"); } incoming.put("name", sb.toString()); } diff --git a/morphium-core/src/main/java/de/caluga/morphium/ObjectMapperImpl.java b/morphium-core/src/main/java/de/caluga/morphium/ObjectMapperImpl.java index a7dd443df..74aade656 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/ObjectMapperImpl.java +++ b/morphium-core/src/main/java/de/caluga/morphium/ObjectMapperImpl.java @@ -87,6 +87,20 @@ public class ObjectMapperImpl implements MorphiumObjectMapper { } } + /** + * Per-class cache for the no-arg constructor used in {@link #deserialize(Class, Map)}. + * Values are either the resolved {@link Constructor} (with {@code setAccessible(true)} + * already applied) or the {@link #NO_NOARG_CONSTRUCTOR} sentinel for classes without an + * accessible no-arg constructor — so the exception-based probe runs only once per class + * instead of on every deserialization (throw/catch in a hot path is expensive). + * {@code null} values cannot be used as the marker: {@code ConcurrentHashMap} treats a + * null mapping as absent and would re-run the probe every call. + * Deliberately an instance (not static) cache — a static {@code Map} would + * hold strong references to entity classes and pin their classloader across redeploys. + */ + private final ConcurrentHashMap < Class, Object > noArgConstructorCache = new ConcurrentHashMap<>(); + private static final Object NO_NOARG_CONSTRUCTOR = new Object(); + private final Map < Class, NameProvider > nameProviders; private final JSONParser jsonParser = new JSONParser(); @@ -360,9 +374,10 @@ public Map serialize(Object o) { try { Class cls = annotationHelper.getRealClass(o.getClass()); + MorphiumTypeMapper customMapper = customMappers.get(cls); - if (customMappers.containsKey(cls)) { - Object ret = customMappers.get(cls).marshall(o); + if (customMapper != null) { + Object ret = customMapper.marshall(o); if (ret instanceof Map) { String typeIdForClass = null; @@ -972,8 +987,10 @@ public T deserialize(Class theClass, Map objec Class cls = theClass; - if (customMappers.containsKey(cls)) { - return (T) customMappers.get(cls).unmarshall(objectMap); + MorphiumTypeMapper classMapper = customMappers.get(cls); + + if (classMapper != null) { + return (T) classMapper.unmarshall(objectMap); } try { @@ -1022,12 +1039,27 @@ public T deserialize(Class theClass, Map objec } Object ret = null; + Object cachedCons = noArgConstructorCache.get(cls); - try { - Constructor cons = cls.getDeclaredConstructor(); - cons.setAccessible(true); - ret = cons.newInstance(); - } catch (Exception ignored) { + if (cachedCons == null) { + // resolve once per class: no-arg constructor (made accessible) or sentinel + try { + Constructor cons = cls.getDeclaredConstructor(); + cons.setAccessible(true); + cachedCons = cons; + } catch (Exception e) { + cachedCons = NO_NOARG_CONSTRUCTOR; + } + + noArgConstructorCache.putIfAbsent(cls, cachedCons); + } + + if (cachedCons instanceof Constructor) { + try { + ret = ((Constructor) cachedCons).newInstance(); + } catch (Exception ignored) { + // constructor exists but threw — fall through to Unsafe, as before + } } if (ret == null) { @@ -1062,8 +1094,10 @@ public T deserialize(Class theClass, Map objec continue; } - if (customMappers.containsKey(fldType)) { - fld.set(ret, customMappers.get(fldType).unmarshall(valueFromDb)); + MorphiumTypeMapper fieldMapper = customMappers.get(fldType); + + if (fieldMapper != null) { + fld.set(ret, fieldMapper.unmarshall(valueFromDb)); continue; } diff --git a/morphium-core/src/main/java/de/caluga/morphium/config/MessagingSettings.java b/morphium-core/src/main/java/de/caluga/morphium/config/MessagingSettings.java index 2471c4ed8..646e4ff01 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/config/MessagingSettings.java +++ b/morphium-core/src/main/java/de/caluga/morphium/config/MessagingSettings.java @@ -161,9 +161,23 @@ public enum RecipientCheck { IGNORE, WARN, THROW } + /** + * What to do when another participant on the same queue runs a DIFFERENT messaging + * implementation (#280). The collection layouts are not interoperable and there is no + * bridge, so a mixed queue loses answers/directed messages silently. Detection runs via + * the layout-independent participants collection every instance announces itself in. + * WARN (default) logs on startup and whenever a mismatched participant joins later; + * THROW refuses to start the mismatched instance (later joins still only WARN - throwing + * from a background thread helps nobody); IGNORE disables announcement and check entirely. + */ + public enum ImplementationCheck { + IGNORE, WARN, THROW + } + private boolean messagingRegistryEnabled = false; private TopicCheck messagingRegistryCheckTopics = TopicCheck.IGNORE; private RecipientCheck messagingRegistryCheckRecipients = RecipientCheck.IGNORE; + private ImplementationCheck messagingImplementationCheck = ImplementationCheck.WARN; private int messagingRegistryUpdateInterval = 30; private long messagingRegistryParticipantTimeout = 65000; private boolean messagingRegistryWaitForInitialSync = false; @@ -208,6 +222,14 @@ public void setMessagingRegistryCheckRecipients(RecipientCheck messagingRegistry this.messagingRegistryCheckRecipients = messagingRegistryCheckRecipients; } + public ImplementationCheck getMessagingImplementationCheck() { + return messagingImplementationCheck; + } + + public void setMessagingImplementationCheck(ImplementationCheck messagingImplementationCheck) { + this.messagingImplementationCheck = messagingImplementationCheck; + } + public int getMessagingRegistryUpdateInterval() { return messagingRegistryUpdateInterval; } diff --git a/morphium-core/src/main/java/de/caluga/morphium/driver/bson/BsonDecoder.java b/morphium-core/src/main/java/de/caluga/morphium/driver/bson/BsonDecoder.java index 452a76818..9d4e570a0 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/driver/bson/BsonDecoder.java +++ b/morphium-core/src/main/java/de/caluga/morphium/driver/bson/BsonDecoder.java @@ -219,6 +219,20 @@ public static int decodeDocumentIn(Map ret, byte[] in, int start idx += 8; break; + case 0x13: { + //decimal128: low 64 bits little-endian first, then high (IEEE 754-2008 BID) + long decLow = readLong(in, idx); + long decHigh = readLong(in, idx + 8); + org.bson.types.Decimal128 dec = org.bson.types.Decimal128.fromIEEE754BIDEncoding(decHigh, decLow); + try { + value = dec.bigDecimalValue(); + } catch (ArithmeticException e) { + value = dec; //NaN/Infinity have no BigDecimal representation + } + idx += 16; + break; + } + case (byte) 0xff: //min key value = new MongoMinKey(); @@ -226,8 +240,8 @@ public static int decodeDocumentIn(Map ret, byte[] in, int start case 0x7f: //max key - //noinspection UnusedAssignment value = new MongoMaxKey(); + break; default: throw new RuntimeException("unknown data type: " + in[idx]); diff --git a/morphium-core/src/main/java/de/caluga/morphium/driver/bson/BsonEncoder.java b/morphium-core/src/main/java/de/caluga/morphium/driver/bson/BsonEncoder.java index a3a960396..f6ed6da5d 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/driver/bson/BsonEncoder.java +++ b/morphium-core/src/main/java/de/caluga/morphium/driver/bson/BsonEncoder.java @@ -137,6 +137,15 @@ public BsonEncoder encodeObject(String n, Object v) { long lng = Double.doubleToLongBits((Double) v); writeLong(lng); + } else if (v instanceof java.math.BigDecimal || v instanceof org.bson.types.Decimal128) { + //decimal128: low 64 bits little-endian first, then high (IEEE 754-2008 BID) + org.bson.types.Decimal128 dec = v instanceof org.bson.types.Decimal128 + ? (org.bson.types.Decimal128) v + : new org.bson.types.Decimal128((java.math.BigDecimal) v); + writeByte(0x13); + cString(n); + writeLong(dec.getLow()); + writeLong(dec.getHigh()); } else if (v instanceof String) { writeByte(2); diff --git a/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/CollectionIndexStore.java b/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/CollectionIndexStore.java index a3d04a54a..1abfc1984 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/CollectionIndexStore.java +++ b/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/CollectionIndexStore.java @@ -77,7 +77,7 @@ public void addIndex(IndexDefinition def, Iterable> existing for (Map doc : existingDocs) { IndexKey key = IndexKey.extract(doc, def); - if (def.unique() && entry.hasBucket(key)) { + if (collidesOnUnique(entry, key, doc)) { throw duplicateKeyException(name, key); } entry.add(key, doc); @@ -125,6 +125,26 @@ public void removeIndex(String name) { indexesByName.remove(name); } + /** + * The subset of {@link #definitions()} a query planner may serve lookups from: everything + * except indexes that have gone multikey. A terminal {@code List} is stored as ONE key rather + * than one entry per element ({@link IndexKey#extract}), so a lookup key built from a scalar + * query value never matches it and an index-backed query would silently answer "no documents" + * where an unindexed collection answers correctly (#289). Excluding such an index sends the + * query back to the scan, which evaluates MongoDB's array semantics properly - correct, just + * not accelerated. Restoring acceleration needs real per-element multikey indexing, which + * {@code IndexKey} already flags as a follow-up. + */ + public Collection planningDefinitions() { + List defs = new ArrayList<>(indexesByName.size()); + for (IndexEntry entry : indexesByName.values()) { + if (!entry.multikey) { + defs.add(entry.definition); + } + } + return Collections.unmodifiableList(defs); + } + /** All currently registered index definitions, including the {@code _id} index. */ public Collection definitions() { List defs = new ArrayList<>(indexesByName.size()); @@ -165,7 +185,7 @@ public void onInsert(Map doc) { for (IndexEntry entry : indexesByName.values()) { IndexKey key = IndexKey.extract(doc, entry.definition); keys.put(entry, key); - if (entry.definition.unique() && entry.hasBucket(key)) { + if (collidesOnUnique(entry, key, doc)) { throw duplicateKeyException(indexNameOf(entry.definition), key); } } @@ -175,6 +195,67 @@ public void onInsert(Map doc) { } } + /** + * Whether inserting {@code doc} under {@code key} would violate {@code entry}'s unique + * constraint. Beyond the plain "some other document already holds this key", two MongoDB rules + * take documents out of an index entirely - and a document that is not in the index cannot + * collide in it: + * + *

    + *
  • {@code sparse}: a document containing none of the indexed fields; + *
  • {@code partialFilterExpression}: a document not matching that query. Note this cuts + * both ways - the incoming document is exempt if it does not match, and an already + * stored document sitting in the same bucket does not count as a collision partner if + * it does not match (the filter may well select on a field that is not part of + * the index key, so uncovered and covered documents share buckets). + *
+ */ + private boolean collidesOnUnique(IndexEntry entry, IndexKey key, Map doc) { + IndexDefinition def = entry.definition; + + if (!def.unique() || (def.sparse() && key.allMissing())) { + return false; + } + + if (def.partialFilterExpression() == null) { + return entry.hasBucket(key); + } + + // Bucket lookup FIRST: it is an O(1) hash probe and empty on the common no-collision + // path, while the filter evaluations below are the expensive half of this check. + List> bucket = entry.bucket(key); + if (bucket == null) { + return false; + } + if (!coveredByPartialFilter(def, doc)) { + return false; + } + for (Map other : bucket) { + if (other != doc && coveredByPartialFilter(def, other)) { + return true; + } + } + return false; + } + + /** + * Whether {@code doc} is part of {@code def}'s index at all, as far as its + * {@code partialFilterExpression} is concerned. Always true for an index without one. + * + *

Documents outside the filter are still stored in the index here (like sparse + * ones): lookups must stay complete, and only the uniqueness check honours the filter. + */ + private static boolean coveredByPartialFilter(IndexDefinition def, Map doc) { + Map filter = def.partialFilterExpression(); + if (filter == null) { + return true; + } + // Prefer the filter compiled once at IndexDefinition construction over + // QueryHelper.matchesQuery, whose global query cache takes a process-wide lock per call. + CompiledQuery compiled = def.compiledPartialFilter(); + return compiled != null ? compiled.matches(doc) : QueryHelper.matchesQuery(filter, doc, null); + } + /** Removes {@code doc} (matched by reference identity) from every index. */ public void onRemove(Map doc) { for (IndexEntry entry : indexesByName.values()) { @@ -214,26 +295,30 @@ public void onUpdate(Map before, Map after) { for (IndexEntry entry : indexesByName.values()) { IndexKey oldKey = IndexKey.extract(before, entry.definition); IndexKey newKey = IndexKey.extract(after, entry.definition); - if (!oldKey.equals(newKey)) { + boolean keyChanged = !oldKey.equals(newKey); + if (keyChanged) { changedEntries.add(entry); oldKeys.add(oldKey); newKeys.add(newKey); } - } - for (int i = 0; i < changedEntries.size(); i++) { - IndexEntry entry = changedEntries.get(i); if (!entry.definition.unique()) { continue; } - IndexKey newKey = newKeys.get(i); - List> bucket = entry.bucket(newKey); - if (bucket != null) { - for (Map other : bucket) { - if (other != after) { - throw duplicateKeyException(indexNameOf(entry.definition), newKey); - } - } + // Uniqueness must be validated not only when the KEY changed: with an unchanged key, + // an update can still move the document INTO a partial index's filter, making it a + // collision partner for covered neighbors already sharing its bucket - mongod raises + // E11000 on exactly that update. Checked here, BEFORE any structural mutation below + // (see the caller-obligation javadoc). collidesOnUnique excludes {@code after} itself + // by reference, so the unchanged-key case (where it already sits in the bucket) is + // safe; the coverage-transition test keeps the check off the plain-update fast path. + boolean check = keyChanged; + if (!check && entry.definition.partialFilterExpression() != null) { + check = !coveredByPartialFilter(entry.definition, before) + && coveredByPartialFilter(entry.definition, after); + } + if (check && collidesOnUnique(entry, newKey, after)) { + throw duplicateKeyException(indexNameOf(entry.definition), newKey); } } @@ -244,6 +329,19 @@ public void onUpdate(Map before, Map after) { } } + /** + * True if a document with {@code id} as its {@code _id} is currently registered in the + * built-in unique {@code _id_} index - a single O(1) hash lookup, no scan. {@code id} is + * normalized the same way stored keys are (see {@link IndexKey#of}), so a + * {@code MorphiumId} caller matches a stored {@code ObjectId} and vice versa. Callers must + * pass a non-null {@code id}: stored null/absent {@code _id}s are filed under + * {@link IndexKey#MISSING}, which a raw {@code null} here would never match. + */ + public boolean containsId(Object id) { + IndexEntry idEntry = indexesByName.get(ID_INDEX_NAME); + return idEntry.hasBucket(IndexKey.of(Collections.singletonList(id))); + } + /** Documents whose extracted key on the named index equals {@code key}, in insertion order. */ public List> equalityLookup(String indexName, IndexKey key) { IndexEntry entry = requireEntry(indexName); @@ -369,6 +467,17 @@ private static final class IndexEntry { final IndexDefinition definition; final Map>> byKey = new HashMap<>(); final TreeMap>> ordered; + /** + * Set once any indexed document holds a {@code List} for one of this index's fields - + * mongod's "multikey" property, learned from the data rather than declared. Since + * {@link IndexKey#extract} keeps such a list as ONE key instead of expanding it per + * element, no lookup key built from a scalar query value can match it, and serving a + * query from this index would silently return nothing (#289). It is therefore excluded + * from {@link #planningDefinitions()} and the query falls back to the scan, which + * evaluates MongoDB's array semantics correctly. Never cleared: once multikey, an index + * stays suspect for its lifetime, exactly as in mongod. + */ + boolean multikey; IndexEntry(IndexDefinition definition) { this.definition = definition; @@ -385,6 +494,12 @@ List> bucket(IndexKey key) { } void add(IndexKey key, Map doc) { + // Every path that populates an index goes through here (createIndex's bulk build, the + // _id index build, onInsert, onUpdate), which makes this the one place that reliably + // sees whether a document turns this index multikey - see the field's javadoc (#289). + if (!multikey && key.hasListValue()) { + multikey = true; + } ArrayList> bucket = byKey.get(key); if (bucket == null) { bucket = new ArrayList<>(); diff --git a/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/CompiledQuery.java b/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/CompiledQuery.java index acec816ca..10f095968 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/CompiledQuery.java +++ b/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/CompiledQuery.java @@ -1059,6 +1059,10 @@ private static Node compileEqLiteral(String key, Map query, Ctx } for (Object candidate : lookup.values) { if (candidate instanceof List) { + if (expected instanceof List + && QueryHelper.listEquals((List) candidate, (List) expected, coll)) { + return true; + } for (Object element : (List) candidate) { if (QueryHelper.compareValues(element, expected, coll)) { return true; @@ -1110,6 +1114,10 @@ private static Node compileEqLiteral(String key, Map query, Ctx return false; } } + if (expected instanceof List + && QueryHelper.listEquals(lst, (List) expected, collUnchecked)) { + return true; + } return lst.contains(expected); } diff --git a/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/InMemTransactionContext.java b/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/InMemTransactionContext.java index 1cccdb3f1..fa9b708a2 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/InMemTransactionContext.java +++ b/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/InMemTransactionContext.java @@ -25,6 +25,22 @@ public class InMemTransactionContext implements MorphiumTransactionContext { */ private final Set touchedCollections = ConcurrentHashMap.newKeySet(); + /** + * Keys ({@code db + "/" + collection}) of every collection whose persistent + * {@link CollectionIndexStore} was actually BUILT (not merely reused) while this + * transaction was active - a strict superset of {@link #touchedCollections}. A read-only + * indexed query (see {@code InMemoryDriver#getDataFromIndex}) can lazily build that store + * from {@code getCollection()}, which resolves against this transaction's private snapshot + * while one is active - i.e. against structurally-cloned document instances, not the live + * ones - without ever writing to the collection and therefore without ever calling + * {@code markCollectionTouched}. A plain reuse of an already-built store can never + * introduce clones (see {@code InMemoryDriver#getIndexStore}), so only builds are recorded + * here. On BOTH commit and abort, every collection recorded here (not just the written + * ones) must have its store invalidated, or a store lazily built from this transaction's + * clones could keep referencing them after the transaction ends. + */ + private final Set indexStoreAccessedCollections = ConcurrentHashMap.newKeySet(); + public Map getDatabase() { return database; } @@ -37,6 +53,10 @@ public Set getTouchedCollections() { return touchedCollections; } + public Set getIndexStoreAccessedCollections() { + return indexStoreAccessedCollections; + } + @Override public Long getTxnNumber() { return null; diff --git a/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/InMemoryDriver.java b/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/InMemoryDriver.java index 6c5d938e1..6c64fae71 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/InMemoryDriver.java +++ b/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/InMemoryDriver.java @@ -316,7 +316,9 @@ private void recordAggregateSlowQueryIfNeeded(String db, String collection, List Map filter = (Map) firstMatch; matchFilter = filter; CollectionIndexStore store = getIndexStore(db, collection); - Collection defs = store.definitions(); + // planningDefinitions(), not definitions(): a multikey index cannot answer a + // lookup and would silently report zero candidates (#289). + Collection defs = store.planningDefinitions(); if (!filter.isEmpty() && defs.size() > 1) { IndexPlanner.IndexPlan plan = IndexPlanner.plan(filter, defs); if (!(plan instanceof IndexPlanner.FullScan)) { @@ -344,7 +346,64 @@ private void recordAggregateSlowQueryIfNeeded(String db, String collection, List * gets rebuilt from scratch on the next read - see {@link #getIndexStore} for the lifecycle * contract every write path must follow. */ - private final Map indexStoreByCollection = new ConcurrentHashMap<>(); + private final Map indexStoreByCollection = new ConcurrentHashMap<>(); + + /** + * Monotonic per-collection invalidation counter ({@code db + "." + collection}, absent means + * "never invalidated"), bumped by every {@link #invalidateIndexStore} BEFORE it removes the + * store. {@link #getIndexStore} reads it before snapshotting the documents in + * {@link #buildIndexStore} and refuses to publish the build if it changed in between - two + * callers reach getIndexStore WITHOUT the collection lock (the ExplainCommand path in + * runCommand and recordAggregateSlowQueryIfNeeded), so their build can race a concurrent + * write+invalidate and would otherwise publish a pre-mutation snapshot AFTER the invalidate, + * serving permanently stale data to every later reader (#290). Entries are deliberately never + * removed (a dropped collection keeps its counter): removal would reopen an ABA window, and + * the cost is one boxed long per namespace ever invalidated. + */ + private final ConcurrentHashMap indexStoreEpochByCollection = new ConcurrentHashMap<>(); + + /** + * Companion to {@link #indexStoreEpochByCollection} for the store removals that do NOT go + * through {@link #invalidateIndexStore}: whole-DB {@link #drop(String, WriteConcern)} and + * {@link #resetData()} discard stores wholesale (bulk removal - a per-key bump cannot cover + * collections whose store was not built yet), so they bump this global counter BEFORE the + * removal instead, with the same publish-fencing contract (#290). + */ + private final AtomicLong indexStoreDropEpoch = new AtomicLong(); + + /** + * A {@link CollectionIndexStore} together with the data provenance it was built from: + * either a specific {@link InMemTransactionContext} (the store holds that transaction's + * cloned documents) or {@link #NO_TRANSACTION} (built from the live database). + * + *

Store and owner live in ONE map value on purpose. Held in two parallel maps they could + * not be published atomically, so a concurrent {@link #getIndexStore} on another thread + * could observe a store whose owner entry was not written yet - or already overwritten by a + * third thread - and reuse it for the wrong caller. That is exactly the confusion the owner + * check exists to prevent, so it must not be re-introduced by the bookkeeping itself. + * + *

Reachability note. A context owner is a strong reference to an + * {@link InMemTransactionContext}, which in turn holds that transaction's whole + * {@code deepCloneDatabase} snapshot. Commit and abort invalidate the entries they own (see + * {@link #commitTransaction}/{@link #abortTransaction}), so in normal operation the clone + * becomes collectable as soon as the transaction ends. An ABANDONED transaction is the + * exception: if a thread dies or a pooled thread's {@code currentTransaction} ThreadLocal is + * never cleared, the entry keeps that entire snapshot reachable until some later caller + * touches the same collection's store and replaces the entry. Bounded (one entry per + * collection) and unlikely, but larger in footprint than the pre-provenance version, which + * pinned only the collection's own cloned documents. + */ + private record OwnedIndexStore(CollectionIndexStore store, Object owner) { + } + + /** + * Sentinel {@link OwnedIndexStore#owner} value marking a store built with no transaction + * active, i.e. one holding live documents. {@code null} is not usable here: it is exactly + * what {@link #currentTransaction}{@code .get()} returns outside a transaction, so a null + * owner could not be told apart from "unknown". + */ + private static final Object NO_TRANSACTION = new Object(); + /** * Counts {@link #buildIndexStore} calls - i.e. full, from-scratch {@code addIndex} rebuilds of @@ -416,6 +475,16 @@ public long getFullBeforeImageCloneCount() { * read/write admin.system.users through the generic paths (which never touch this mutex). * Deadlock-free: this mutex is always acquired BEFORE the users collection lock and nothing * acquires it while holding any collection lock, so no lock-order cycle exists. + * + *

SCOPE (honest limits, 2026-08-06 review): the ordering guarantee holds only among the + * user writes that take this mutex - createUser/updateUser/dropUser vs each other. It does + * NOT cover (a) RAW deletes on admin.system.users (the generic delete path does not take + * this mutex and can still get its token inverted relative to a concurrent create/update - + * use dropUser), and (b) cross-namespace inversion: a concurrent write to + * any OTHER collection can be assigned a higher token yet complete delivery before a user + * event - combined with a resume via max-seen-token (PoppyDB's lastAppliedSequence), a + * reconnecting secondary can then skip the user event until the next full resync. Both are + * follow-up tickets, not properties this lock provides. */ private final java.util.concurrent.locks.ReentrantLock userWriteEmitLock = new java.util.concurrent.locks.ReentrantLock(); private final List hostSeed = new CopyOnWriteArrayList<>(); @@ -439,6 +508,24 @@ public long getFullBeforeImageCloneCount() { // primary all buffer mutations come from a single writer thread, and any drift is transient and // self-correcting — it only loosens the eviction bound slightly and never corrupts the deque. private final AtomicInteger changeStreamHistorySize = new AtomicInteger(); + // Byte budget for the replay buffer (spec: 2026-08-14-replay-buffer-byte-budget.md). The + // count limit alone does not bound memory: every buffered event retains its full document, + // so bulk writes of large documents can pin GBs (ACC incident 2026-08-14: 100k events held + // ~4GB live while the visible collections stayed under 1MB). Once the estimated buffered + // bytes exceed the budget, oldest events are evicted - identical window-lost semantics as + // count overflow, a disconnected consumer simply has to re-sync. 0 = no byte bound (core + // default, unchanged behaviour; PoppyDB opts in - planned to become the default in 7.0). + private volatile long changeStreamHistoryByteBudget = 0; + // Estimated bytes currently buffered; same best-effort consistency contract as + // changeStreamHistorySize above (single writer on the PoppyDB primary, transient drift + // only loosens the bound). Every deque mutation site maintains it via the event's + // estimatedBytes field. + private final AtomicLong changeStreamHistoryBytes = new AtomicLong(); + // Monotonic count of events evicted because of the byte budget (not the count limit) - + // diagnostic only, exposed in serverStatus like mongod's oplogTruncation counters. + private final AtomicLong changeStreamHistoryEvictedForBudget = new AtomicLong(); + // Rate limit for the budget-eviction WARN log (at most one per minute). + private volatile long lastBudgetEvictWarnAt = 0; // Track the sequence number at the time of the last drop per namespace (db.collection or db). // replayHistory skips events older than this to prevent stale events from being replayed. private final ConcurrentHashMap lastDropSequence = new ConcurrentHashMap<>(); @@ -505,11 +592,17 @@ private void addResultAndQueue(int id, Map res) { Integer.getInteger("inmemory.scheduledThreads", DEFAULT_EXEC_THREADS)); // Executor for dispatching change stream events asynchronously // This prevents insert/update/delete operations from blocking on event delivery - // Platform threads on purpose: virtual threads can deadlock the whole JVM here + // SINGLE thread on purpose: mongod guarantees per-cursor event ordering, and a pool + // does not preserve submission order — with the former cached pool two back-to-back + // events could reach a subscriber swapped (or even concurrently) under CPU load, + // see ChangeStreamEventOrderingTest. The queue is unbounded, so writers still never + // block; serverMode doesn't use this executor at all (synchronous delivery for + // replication ordering and backpressure, see dispatchEvent). + // Platform thread on purpose: virtual threads can deadlock the whole JVM here // under JDK 21 — dispatchers pinned on the logback appender lock occupy all // carriers while the unmounted lock holder never gets scheduled again (#234). private final java.util.concurrent.ExecutorService eventDispatcher = java.util.concurrent.Executors - .newCachedThreadPool( + .newSingleThreadExecutor( Thread.ofPlatform().name("event-dispatcher-", 0).daemon(true).factory()); private boolean running = true; private int expireCheck = 10000; @@ -521,10 +614,14 @@ private void addResultAndQueue(int id, Map res) { private static class TtlIndexInfo { final String fieldName; final int expireAfterSeconds; + // mongod's TTL monitor deletes only documents the index actually covers - a TTL index + // with a partialFilterExpression must leave uncovered documents alone (review 2026-08-13) + final Map partialFilterExpression; - TtlIndexInfo(String fieldName, int expireAfterSeconds) { + TtlIndexInfo(String fieldName, int expireAfterSeconds, Map partialFilterExpression) { this.fieldName = fieldName; this.expireAfterSeconds = expireAfterSeconds; + this.partialFilterExpression = partialFilterExpression; } } @@ -827,6 +924,9 @@ public void resetData() { cappedCurrentBytesByCollection.clear(); collectionsWithTtlIndex.clear(); ttlQueueByCollection.clear(); + // Bump BEFORE the clear - see drop(): fences a build racing this reset out of + // re-publishing a pre-reset snapshot (#290). + indexStoreDropEpoch.incrementAndGet(); indexStoreByCollection.clear(); for (var m : monitors) { @@ -837,6 +937,8 @@ public void resetData() { changeStreamSubscribers.clear(); changeStreamHistory.clear(); changeStreamHistorySize.set(0); + changeStreamHistoryBytes.set(0); + changeStreamHistoryEvictedForBudget.set(0); changeStreamSequence.set(0); lastDropSequence.clear(); lastGlobalDropSequence.set(0); @@ -1382,6 +1484,9 @@ private int handleValidate(Map cmdMap) { private volatile int memoryRejectPercent = 90; private final AtomicBoolean memoryWarnActive = new AtomicBoolean(false); private static final ThreadLocal memoryGuardBypass = ThreadLocal.withInitial(() -> Boolean.FALSE); + // See suppressChangeStreamEvents(): thread-local because the replication initial sync runs on + // its own dedicated thread, and only THAT thread's writes must go unobserved. + private static final ThreadLocal changeStreamSuppressed = ThreadLocal.withInitial(() -> Boolean.FALSE); /** Warn/reject thresholds in percent of max heap; 100 disables the respective stage. */ public void setMemoryWatermarks(int warnPercent, int rejectPercent) { @@ -1463,6 +1568,32 @@ public void close() { } } + /** + * try-with-resources scope during which writes performed by this thread emit NO change-stream + * events: nothing is recorded into the change-stream history and nothing is dispatched to + * subscribers. + * + *

Used by PoppyDB's replication initial sync (wipe + snapshot copy), mirroring MongoDB's + * semantics that initial-sync writes are never oplogged. Without this, a re-syncing secondary + * broadcasts its own {@code clearLocalDatabases()} wipe as live {@code drop} events - and + * during a leadership transition the OTHER nodes' still-running old ReplicationManagers + * (watching the demoted ex-primary) faithfully apply those drops to their own data, + * destroying {@code admin.system.users} cluster-wide (observed as the + * StepdownReplicationTest flake: the freshly-promoted primary itself applied the demoted + * node's wipe-drop right before/while being promoted). + */ + public ChangeStreamSuppressionScope suppressChangeStreamEvents() { + changeStreamSuppressed.set(Boolean.TRUE); + return new ChangeStreamSuppressionScope(); + } + + public static final class ChangeStreamSuppressionScope implements AutoCloseable { + @Override + public void close() { + changeStreamSuppressed.set(Boolean.FALSE); + } + } + private void checkMemoryWatermark() throws MorphiumDriverException { if (memoryWarnPercent >= 100 && memoryRejectPercent >= 100) { return; @@ -1520,7 +1651,7 @@ public Set getSupportedCommandNames() { } names.addAll(Set.of("serverStatus", "bulkWrite", "saslStart", "saslContinue", "createUser", "updateUser", - "registerMessagingCollection", "unregisterMessagingSubscriber", "dbHash", "validate")); + "dropUser", "registerMessagingCollection", "unregisterMessagingSubscriber", "dbHash", "validate")); return names; } @@ -1561,7 +1692,8 @@ private Map findUserDocument(String authDb, String user) { return null; } - private int createUserInternal(String db, String user, String pwd, List roles, List mechanisms) { + private int createUserInternal(String db, String user, String pwd, List roles, List mechanisms, + Map customData) { // Fast pre-lock check only for the common "already exists" answer - the authoritative // check happens under the write lock below, because two concurrent createUsers must not // both act on the same pre-lock snapshot (that was the TOCTOU: both passed this check @@ -1577,11 +1709,21 @@ private int createUserInternal(String db, String user, String pwd, List // (~ms range) and a losing racer simply discards the document Map doc = de.caluga.morphium.driver.inmem.auth.UserDocuments .buildUserDocument(db, user, pwd, roles, mechanisms); - List> users = getCollection(USERS_DB, USERS_COLLECTION); - + if (customData != null) { + doc.put("customData", customData); + } // held across store + notify so stream order equals store order - see userWriteEmitLock userWriteEmitLock.lock(); try { + // Resolved INSIDE the lock on purpose: getCollection() CREATES the collection when + // it does not exist yet. Fetched before locking, two concurrent user writes each + // created their own list, then checked that (empty) list and acted on it - the + // last put() won the map, so exactly one document survived while both callers were + // told they had succeeded. This is the residual half of the createUser TOCTOU: the + // earlier fix closed the "both see no user" window but left the "both create the + // collection" one, which opens only on the very first user write of a fresh + // instance - exactly what a concurrency test does. + List> users = getCollection(USERS_DB, USERS_COLLECTION); java.util.concurrent.locks.ReadWriteLock lock = getCollectionLock(USERS_DB, USERS_COLLECTION); lock.writeLock().lock(); try { @@ -1594,6 +1736,14 @@ private int createUserInternal(String db, String user, String pwd, List } users.add(doc); + // The user-write paths mutate this list directly, bypassing the generic + // write path that maintains CollectionIndexStore. Without this the store + // for admin.system.users goes permanently stale once anything has built + // it (any generic find/count/insert on that namespace does), so a generic + // insert's duplicate-_id check would not see users created by command, + // and index-backed finds would miss them. Invalidate rather than patch - + // same contract createIndex uses; the next read rebuilds it. + invalidateIndexStore(USERS_DB, USERS_COLLECTION); } finally { lock.writeLock().unlock(); } @@ -1616,6 +1766,13 @@ private int createUserInternal(String db, String user, String pwd, List * new credentials cryptographically to the old password) and/or replaces {@code roles}. * {@code buildUserDocument}'s {@code _id} is derived from db+user alone, so the replacement * document keeps the same {@code _id} as the document it replaces without any extra bookkeeping. + * + *

Mechanism semantics follow mongod: a pwd change WITHOUT {@code mechanisms} preserves the + * user's existing mechanism set (it does not reset to the both-mechanisms default), and + * {@code mechanisms} without {@code pwd} is a subset-only update that keeps the stored + * credentials of the named mechanisms and drops the rest. {@code customData} follows mongod + * too: replaced wholesale when given (also as the only field), preserved when omitted. + * Not modeled: {@code authenticationRestrictions}. */ private int updateUserInternal(Map cmdMap) { String db = (String) cmdMap.get("$db"); @@ -1625,14 +1782,49 @@ private int updateUserInternal(Map cmdMap) { return errorResult(2, "BadValue", "updateUser requires a user name"); } - String pwd = (String) cmdMap.get("pwd"); + // Shape-check every optional field BEFORE casting: a client sending e.g. roles as a + // string must get a mongod-style BadValue command error, not an uncaught + // ClassCastException out of the command handler. + Object pwdRaw = cmdMap.get("pwd"); + if (pwdRaw != null && (!(pwdRaw instanceof String) || ((String) pwdRaw).isBlank())) { + return errorResult(2, "BadValue", "pwd must be a non-empty string"); + } + String pwd = (String) pwdRaw; + + Object rolesRaw = cmdMap.get("roles"); + if (rolesRaw != null && !(rolesRaw instanceof List)) { + return errorResult(2, "BadValue", "roles must be an array"); + } @SuppressWarnings("unchecked") - List roles = (List) cmdMap.get("roles"); + List roles = (List) rolesRaw; + + Object mechanismsRaw = cmdMap.get("mechanisms"); + if (mechanismsRaw != null && !(mechanismsRaw instanceof List)) { + return errorResult(2, "BadValue", "mechanisms must be an array"); + } @SuppressWarnings("unchecked") - List mechanisms = (List) cmdMap.get("mechanisms"); + List mechanisms = (List) mechanismsRaw; + if (mechanisms != null) { + if (mechanisms.isEmpty()) { + return errorResult(2, "BadValue", "mechanisms field must not be empty"); + } + for (Object m : (List) mechanisms) { + if (!(m instanceof String)) { + return errorResult(2, "BadValue", "mechanisms must be an array of strings"); + } + } + } - if (pwd == null && roles == null) { - return errorResult(2, "BadValue", "updateUser requires at least one of pwd or roles"); + Object customDataRaw = cmdMap.get("customData"); + if (customDataRaw != null && !(customDataRaw instanceof Map)) { + return errorResult(2, "BadValue", "customData must be a document"); + } + @SuppressWarnings("unchecked") + Map customData = (Map) customDataRaw; + + if (pwd == null && roles == null && mechanisms == null && customData == null) { + return errorResult(2, "BadValue", + "updateUser requires at least one of pwd, roles, mechanisms or customData"); } // Fast pre-lock check only for the common "no such user" answer. The authoritative @@ -1647,11 +1839,18 @@ private int updateUserInternal(Map cmdMap) { try { Map replacement; - List> users = getCollection(USERS_DB, USERS_COLLECTION); - // held across store + notify so stream order equals store order - see userWriteEmitLock userWriteEmitLock.lock(); try { + // Resolved INSIDE the lock on purpose: getCollection() CREATES the collection when + // it does not exist yet. Fetched before locking, two concurrent user writes each + // created their own list, then checked that (empty) list and acted on it - the + // last put() won the map, so exactly one document survived while both callers were + // told they had succeeded. This is the residual half of the createUser TOCTOU: the + // earlier fix closed the "both see no user" window but left the "both create the + // collection" one, which opens only on the very first user write of a fresh + // instance - exactly what a concurrency test does. + List> users = getCollection(USERS_DB, USERS_COLLECTION); java.util.concurrent.locks.ReadWriteLock lock = getCollectionLock(USERS_DB, USERS_COLLECTION); lock.writeLock().lock(); try { @@ -1671,15 +1870,72 @@ private int updateUserInternal(Map cmdMap) { if (pwd != null) { @SuppressWarnings("unchecked") List effectiveRoles = roles != null ? roles : (List) current.get("roles"); + // mongod preserves the user's existing mechanism set when the command + // omits "mechanisms" - passing null through to buildUserDocument would + // instead reset to BOTH defaults, silently re-arming SCRAM-SHA-1 + // credentials for a user deliberately created SHA-256-only + // (2026-08-06 review finding). + List effectiveMechanisms = mechanisms; + if (effectiveMechanisms == null && current.get("credentials") instanceof Map) { + @SuppressWarnings("unchecked") + Map currentCreds = (Map) current.get("credentials"); + effectiveMechanisms = new ArrayList<>(currentCreds.keySet()); + } replacement = de.caluga.morphium.driver.inmem.auth.UserDocuments - .buildUserDocument(db, user, pwd, effectiveRoles, mechanisms); + .buildUserDocument(db, user, pwd, effectiveRoles, effectiveMechanisms); + // buildUserDocument creates a fresh document - customData would silently + // vanish on every pwd change without this carry-over (mongod preserves it + // when omitted, replaces it wholesale when given) + Object effectiveCustomData = customData != null ? customData : current.get("customData"); + if (effectiveCustomData != null) { + replacement.put("customData", effectiveCustomData); + } } else { replacement = new LinkedHashMap<>(current); - replacement.put("roles", roles); + if (roles != null) { + replacement.put("roles", roles); + } + if (customData != null) { + replacement.put("customData", customData); + } + if (mechanisms != null) { + // mongod: mechanisms without pwd is legal only as a SUBSET of the + // user's existing mechanisms - the stored credentials for the named + // mechanisms are kept verbatim (they can't be re-derived without the + // password), all others are dropped. + @SuppressWarnings("unchecked") + Map currentCreds = current.get("credentials") instanceof Map + ? (Map) current.get("credentials") + : java.util.Map.of(); + Map keptCreds = new LinkedHashMap<>(); + for (Object m : (List) mechanisms) { + Object cred = currentCreds.get(m); + if (cred == null) { + return errorResult(2, "BadValue", + "mechanisms field must be a subset of previously set mechanisms"); + } + keptCreds.put((String) m, cred); + } + replacement.put("credentials", keptCreds); + } } users.removeIf(doc -> id.equals(doc.get("_id"))); users.add(replacement); + // The user-write paths mutate this list directly, bypassing the generic + // write path that maintains CollectionIndexStore. Without this the store + // for admin.system.users goes permanently stale once anything has built + // it (any generic find/count/insert on that namespace does), so a generic + // insert's duplicate-_id check would not see users created by command, + // and index-backed finds would miss them. Invalidate rather than patch - + // same contract createIndex uses; the next read rebuilds it. + // + // AFTER the add, not between remove and add: getIndexStore() is reachable + // with no collection lock held (the explain path and the slow-query + // recorder - see its javadoc), so a rebuild landing in that gap would + // publish a store built from a list the user is momentarily missing from, + // and nothing would invalidate it again. + invalidateIndexStore(USERS_DB, USERS_COLLECTION); } finally { lock.writeLock().unlock(); } @@ -1701,6 +1957,83 @@ private int updateUserInternal(Map cmdMap) { return requestId; } + /** + * mongod-compatible {@code dropUser}: removes the user document and emits a delete event on + * admin.system.users (documentKey-keyed, same shape as the generic delete path - PoppyDB + * secondaries replicate the drop by applying exactly that delete). Runs under + * {@code userWriteEmitLock} so the delete event gets the same store-order-equals-token-order + * guarantee as createUser/updateUser - without it, a drop racing a concurrent create/update + * of the same user could invert token order and make secondaries converge on the wrong + * state (the gap the 2026-08-06 review documented for raw deletes). + */ + private int dropUserInternal(Map cmdMap) { + String db = (String) cmdMap.get("$db"); + Object nameRaw = cmdMap.get("dropUser"); + + if (!(nameRaw instanceof String) || ((String) nameRaw).isBlank()) { + return errorResult(2, "BadValue", "dropUser requires a user name"); + } + String user = (String) nameRaw; + + // Fast pre-lock check for the common "no such user" answer; authoritative resolve + // happens under the write lock below (same discipline as create/update). + if (findUserDocument(db, user) == null) { + return errorResult(11, "UserNotFound", "User \"" + user + "@" + db + "\" not found"); + } + + String id = de.caluga.morphium.driver.inmem.auth.UserDocuments.userId(db, user); + + try { + Map removed = null; + // held across store + notify so stream order equals store order - see userWriteEmitLock + userWriteEmitLock.lock(); + try { + // Resolved INSIDE the lock on purpose: getCollection() CREATES the collection when + // it does not exist yet. Fetched before locking, two concurrent user writes each + // created their own list, then checked that (empty) list and acted on it - the + // last put() won the map, so exactly one document survived while both callers were + // told they had succeeded. This is the residual half of the createUser TOCTOU: the + // earlier fix closed the "both see no user" window but left the "both create the + // collection" one, which opens only on the very first user write of a fresh + // instance - exactly what a concurrency test does. + List> users = getCollection(USERS_DB, USERS_COLLECTION); + java.util.concurrent.locks.ReadWriteLock lock = getCollectionLock(USERS_DB, USERS_COLLECTION); + lock.writeLock().lock(); + try { + for (java.util.Iterator> it = users.iterator(); it.hasNext(); ) { + Map doc = it.next(); + if (id.equals(doc.get("_id"))) { + removed = doc; + it.remove(); + // see createUserInternal: this path bypasses the generic write path + // that maintains CollectionIndexStore, so the store must be dropped + // or an index-backed find would keep returning the dropped user. + invalidateIndexStore(USERS_DB, USERS_COLLECTION); + break; + } + } + + if (removed == null) { + // lost the race against a concurrent drop since the pre-lock check + return errorResult(11, "UserNotFound", "User \"" + user + "@" + db + "\" not found"); + } + } finally { + lock.writeLock().unlock(); + } + // same event shape as the generic delete path: op "delete", beforeDocument set + notifyWatchers(USERS_DB, USERS_COLLECTION, "delete", removed, null, null, removed); + } finally { + userWriteEmitLock.unlock(); + } + } catch (MorphiumDriverException e) { + return errorResult(1, "InternalError", "could not drop user: " + e.getMessage()); + } + + int requestId = commandNumber.incrementAndGet(); + addResult(requestId, prepareResult(Doc.of("ok", 1.0))); + return requestId; + } + /** Payload arrives as byte[] from morphium's client, defensively also accept String. */ private static String payloadAsString(Object payload) { if (payload instanceof byte[] b) { @@ -1815,7 +2148,8 @@ public int runCommand(CreateUserAdminCommand cmd) { } List roles = cmd.getRoles() == null ? new ArrayList<>() : new ArrayList(cmd.getRoles()); - return createUserInternal(cmd.getDb(), cmd.getUserName(), cmd.getPwd(), roles, cmd.getMechanisms()); + return createUserInternal(cmd.getDb(), cmd.getUserName(), cmd.getPwd(), roles, cmd.getMechanisms(), + cmd.getCustomData()); } public int runCommand(CreateRoleAdminCommand cmd) { @@ -1896,7 +2230,9 @@ public int runCommand(ExplainCommand cmd) throws MorphiumDriverException { } CollectionIndexStore store = getIndexStore(db, coll); - Collection defs = store.definitions(); + // planningDefinitions(), not definitions(): a multikey index cannot answer a lookup and + // would silently report zero candidates (#289). + Collection defs = store.planningDefinitions(); IndexPlanner.IndexPlan plan = (query.isEmpty() || defs.size() <= 1) ? IndexPlanner.FullScan.INSTANCE : IndexPlanner.plan(query, defs); @@ -2067,14 +2403,20 @@ public int runCommand(GenericCommand cmd) { List roles = (List) cmdMap.get("roles"); @SuppressWarnings("unchecked") List mechanisms = (List) cmdMap.get("mechanisms"); + @SuppressWarnings("unchecked") + Map customData = (Map) cmdMap.get("customData"); return createUserInternal((String) cmdMap.get("$db"), (String) cmdMap.get("createUser"), - (String) cmdMap.get("pwd"), roles, mechanisms); + (String) cmdMap.get("pwd"), roles, mechanisms, customData); } if (commandName.equals("updateUser")) { return updateUserInternal(cmdMap); } + if (commandName.equals("dropUser")) { + return dropUserInternal(cmdMap); + } + // serverStatus and the top-level bulkWrite (MongoDB 8.0 shape) have no typed command // class, so the reflective dispatch below cannot resolve them - answer them from the // raw map here (#257) @@ -2214,6 +2556,28 @@ private int handleServerStatus() { "heapUsedAfterGcPercent", Math.round(heapUsedAfterGcPercent() * 10) / 10.0, "warnPercent", memoryWarnPercent, "rejectPercent", memoryRejectPercent, "warnActive", memoryWarnActive.get())); + // Replay-buffer state. Primary operational metric is the retained resume window in + // seconds - the analogue of mongod's oplog "log length start to end" + // (rs.printReplicationInfo()): how much consumer/secondary downtime is still resumable + // without a re-sync. peekFirst/peekLast are O(1); under concurrent eviction the two + // reads are not atomic, which at worst skews a diagnostic value transiently. + ChangeStreamEventInfo histFirst = changeStreamHistory.peekFirst(); + ChangeStreamEventInfo histLast = changeStreamHistory.peekLast(); + Doc replayBuffer = Doc.of("events", changeStreamHistorySize.get(), + "bytes", changeStreamHistoryBytes.get(), + "budgetBytes", changeStreamHistoryByteBudget, + "limitEvents", changeStreamHistoryLimit, + "evictedForBudget", changeStreamHistoryEvictedForBudget.get()); + + if (histFirst != null && histLast != null) { + replayBuffer.put("firstEventTime", new Date(histFirst.createdAt)); + replayBuffer.put("lastEventTime", new Date(histLast.createdAt)); + replayBuffer.put("windowSeconds", Math.max(0, (histLast.createdAt - histFirst.createdAt) / 1000)); + } else { + replayBuffer.put("windowSeconds", 0L); + } + + m.put("changeStreamReplayBuffer", replayBuffer); addResult(ret, m); return ret; } @@ -2957,7 +3321,7 @@ public int runCommand(InsertMongoCommand cmd) throws MorphiumDriverException { List> writeErrors = insert(cmd.getDb(), cmd.getColl(), cmd.getDocuments(), cmd.getWriteConcern(), ordered); var m = prepareResult(); - m.put("n", cmd.getDocuments().size() - writeErrors.size()); + m.put("n", insertedCountFromWriteErrors(cmd.getDocuments().size(), ordered, writeErrors)); if (writeErrors.size() != 0) { m.put("writeErrors", writeErrors); } @@ -4127,40 +4491,50 @@ public void connect() { } private void scheduleExpire() { - expire = exec.scheduleWithFixedDelay(() -> { - // Only check collections that have TTL indexes - skip all others - if (collectionsWithTtlIndex.isEmpty()) { - return; - } + expire = exec.scheduleWithFixedDelay(this::runTtlSweepPass, 100, expireCheck, TimeUnit.MILLISECONDS); + } - try { - for (Map.Entry entry : collectionsWithTtlIndex.entrySet()) { - String key = entry.getKey(); - TtlIndexInfo ttlInfo = entry.getValue(); - - // Parse db.collection from key - int dotIdx = key.indexOf('.'); - if (dotIdx < 0) continue; - String db = key.substring(0, dotIdx); - String coll = key.substring(dotIdx + 1); - - // Check if collection still exists - if (!database.containsKey(db) || !database.get(db).containsKey(coll)) { - collectionsWithTtlIndex.remove(key); - invalidateTtlQueue(db, coll); - continue; - } + /** + * One full pass of the background TTL expiration check: {@link #sweepTtlQueue} for every + * registered TTL collection that still exists, deregistering the ones that don't. This is the + * body of the scheduled task in {@link #scheduleExpire} - package-private rather than an inline + * lambda so same-package tests can drive a sweep deterministically instead of racing the + * scheduler (same motivation as the package-private {@code ttlEntriesChecked} counter). Never + * throws: a failure on one collection must not kill the recurring task. + */ + /* package-private */ void runTtlSweepPass() { + // Only check collections that have TTL indexes - skip all others + if (collectionsWithTtlIndex.isEmpty()) { + return; + } - try { - sweepTtlQueue(db, coll, key, ttlInfo); - } catch (Exception e) { - log.error("Error processing TTL for {}", key, e); - } + try { + for (Map.Entry entry : collectionsWithTtlIndex.entrySet()) { + String key = entry.getKey(); + TtlIndexInfo ttlInfo = entry.getValue(); + + // Parse db.collection from key + int dotIdx = key.indexOf('.'); + if (dotIdx < 0) continue; + String db = key.substring(0, dotIdx); + String coll = key.substring(dotIdx + 1); + + // Check if collection still exists + if (!database.containsKey(db) || !database.get(db).containsKey(coll)) { + collectionsWithTtlIndex.remove(key); + invalidateTtlQueue(db, coll); + continue; + } + + try { + sweepTtlQueue(db, coll, key, ttlInfo); + } catch (Exception e) { + log.error("Error processing TTL for {}", key, e); } - } catch (Exception e) { - log.error("Error in TTL expiration check", e); } - }, 100, expireCheck, TimeUnit.MILLISECONDS); + } catch (Exception e) { + log.error("Error in TTL expiration check", e); + } } /** @@ -4237,6 +4611,14 @@ private void sweepTtlQueue(String db, String coll, String key, TtlIndexInfo ttlI // already pushed a fresh entry reflecting the new expiry; this one is stale. continue; } + if (ttlInfo.partialFilterExpression != null + && !QueryHelper.matchesQuery(ttlInfo.partialFilterExpression, doc, null)) { + // A document outside the index's partialFilterExpression is not part of the + // TTL index - mongod's TTL monitor never deletes it. Checked against the LIVE + // document, so a later transition into the filter still expires normally via + // the next enqueued entry. + continue; + } collectionData.remove(doc); indexStore.onRemove(doc); @@ -4282,9 +4664,21 @@ private static Long ttlComputeFieldEpochMs(Object fieldValue) { * remove a document's OLD queue entry: {@link #sweepTtlQueue} re-checks a popped entry against * the live document and silently discards it if stale, which is cheaper than a queue-wide * search here and keeps this a pure O(1) push. + * + *

Bootstrap on miss (#269). A missing entry means the queue was discarded by a + * structural change ({@link #invalidateTtlQueue}) and no sweep tick has rebuilt it yet. This + * must then do exactly what {@link #sweepTtlQueue}'s own miss branch does - a full + * {@link #ttlBootstrapQueue} - and NOT simply start a fresh queue holding only {@code doc}: + * that fresh queue is no longer {@code null}, so the sweep's bootstrap-on-miss never fires + * again and every OLDER document silently loses its expiry tracking for good. In practice that + * meant an unbounded messaging collection: {@code Msg.deleteAt} is TTL-indexed, so a single + * insert landing in the window between an invalidation and the next sweep tick stopped every + * already-stored message from ever expiring. */ - private void ttlEnqueue(String db, String collection, Map doc) { - TtlIndexInfo ttlInfo = collectionsWithTtlIndex.get(db + "." + collection); + private void ttlEnqueue(String db, String collection, Map doc) + throws MorphiumDriverException { + String key = db + "." + collection; + TtlIndexInfo ttlInfo = collectionsWithTtlIndex.get(key); if (ttlInfo == null) { return; } @@ -4293,8 +4687,40 @@ private void ttlEnqueue(String db, String collection, Map doc) { return; } long expiryEpochMs = fieldEpochMs + ttlInfo.expireAfterSeconds * 1000L; - ttlQueueByCollection.computeIfAbsent(db + "." + collection, k -> new PriorityQueue<>()) - .add(new TtlQueueEntry(expiryEpochMs, doc.get("_id"))); + PriorityQueue queue = ttlQueueByCollection.get(key); + if (queue == null) { + // Rebuild from the collection's current contents rather than starting empty. Safe under + // the write lock every caller of this method already holds (insert/storeInternal/ + // updateInternal) - which is also what ttlBootstrapQueue requires. + ttlBootstrapQueue(db, collection, ttlInfo); + queue = ttlQueueByCollection.get(key); + // Every call site runs AFTER "doc" is physically in the collection and in the index + // store (see getIndexStore's lifecycle contract), so the scan just performed has + // normally already queued it - adding it again here would double-enqueue it. The + // bootstrap can legitimately miss it though (no TTL index definition in the store to + // scan, e.g. after a rename, which does not carry index definitions over), so check + // rather than assume. A linear scan is fine: it only ever runs on the rare + // once-per-invalidation rebuild, which is itself O(collection size). + if (ttlQueueContains(queue, expiryEpochMs, doc.get("_id"))) { + return; + } + } + queue.add(new TtlQueueEntry(expiryEpochMs, doc.get("_id"))); + } + + /** + * True if {@code queue} already holds an entry for exactly this {@code docId}/expiry pair - + * the double-add guard for {@link #ttlEnqueue}'s bootstrap-on-miss branch. Compares by value + * rather than by {@link TtlQueueEntry} identity on purpose: the bootstrap builds brand-new + * entry objects, so identity would never match. + */ + private static boolean ttlQueueContains(PriorityQueue queue, long expiryEpochMs, Object docId) { + for (TtlQueueEntry e : queue) { + if (e.expiryEpochMs == expiryEpochMs && Objects.equals(e.docId, docId)) { + return true; + } + } + return false; } /** @@ -4334,10 +4760,13 @@ private void ttlBootstrapQueue(String db, String collection, TtlIndexInfo ttlInf * Discards {@code db.collection}'s expiry queue, mirroring {@link #invalidateIndexStore}'s * discard-and-rebuild-on-next-access pattern: called at every structural change (drop, clear, * rename, transaction commit replacing a collection's document list) where queued entries - * could otherwise point at stale expiry times. The next TTL sweep tick that finds a missing - * queue for a still-TTL-indexed collection rebuilds it lazily via {@link #ttlBootstrapQueue} - - * same lazy-rebuild contract as the index store, so callers here don't need the write lock (a - * freshly discarded queue is always a safe state to leave behind). + * could otherwise point at stale expiry times. Whichever comes first - the next TTL sweep tick + * or the next insert/update of a TTL-bearing document - rebuilds the queue lazily via + * {@link #ttlBootstrapQueue} (see {@link #sweepTtlQueue}'s and {@link #ttlEnqueue}'s miss + * branches; BOTH must bootstrap, or the one that doesn't leaves a queue behind that stops the + * other from ever rebuilding - see #269). Same lazy-rebuild contract as the index store, so + * callers here don't need the write lock (a freshly discarded queue is always a safe state to + * leave behind). */ private void invalidateTtlQueue(String db, String collection) { ttlQueueByCollection.remove(db + "." + collection); @@ -4994,6 +5423,13 @@ public List> find(String db, String collection, Map> find(String db, String collection, Map query, + Map sort, Map projection, + Map collation, int skip, int limit) + throws MorphiumDriverException { + return find(db, collection, query, sort, projection, collation, skip, limit, false); + } + private java.util.concurrent.locks.ReadWriteLock getCollectionLock(String db, String collection) { String key = db + "." + collection; return collectionLocks.computeIfAbsent(key, k -> new java.util.concurrent.locks.ReentrantReadWriteLock()); @@ -5203,7 +5639,8 @@ private List> find(String db, String collection, Map> indexSortIterator = null; if (sort != null && !sort.isEmpty() && QueryHelper.getCollator(collation) == null) { CollectionIndexStore indexStore = getIndexStore(db, collection); - Collection defs = indexStore.definitions(); + // planningDefinitions(), not definitions() - see getDataFromIndex (#289). + Collection defs = indexStore.planningDefinitions(); if (defs.size() > 1) { IndexPlanner.IndexPlan filterPlan = IndexPlanner.plan(query, defs); indexSortIterator = planIndexOrderedIterator(indexStore, defs, filterPlan, sort); @@ -5771,10 +6208,13 @@ private static Object applyElemMatchProjection(String field, Object arrayVal, Ma * Returns the persistent {@link CollectionIndexStore} for {@code db.collection}, building it * on first access from every currently defined non-{@code _id} index * ({@link #isDefaultIdDefinition}) and the collection's current documents - * ({@link CollectionIndexStore#addIndex}). Once built, a store lives forever (until an - * invalidating structural change - see {@link #invalidateIndexStore}) and is kept in sync by - * every write path calling {@code onInsert}/{@code onUpdate}/{@code onRemove} on it directly, - * which is why - unlike Task 3's rebuild-on-miss cache - there is no epoch/version check here. + * ({@link CollectionIndexStore#addIndex}). Once built, a store lives until an invalidating + * structural change (see {@link #invalidateIndexStore}) and is kept in sync by every write + * path calling {@code onInsert}/{@code onUpdate}/{@code onRemove} on it directly, so - unlike + * Task 3's rebuild-on-miss cache - there is no epoch/version check on its CONTENT. There is, + * however, a check on its PROVENANCE: a store is only handed to the caller whose data it was + * built from, since the same map has to serve both live documents and per-transaction clones. + * See the reuse conditions inline below. * *

Lifecycle contract for write paths. A mutation entry point MUST call this method * (or otherwise be sure the store already exists) BEFORE mutating the collection's document @@ -5793,16 +6233,136 @@ private static Object applyElemMatchProjection(String field, Object arrayVal, Ma */ /* package-private */ CollectionIndexStore getIndexStore(String db, String collection) throws MorphiumDriverException { String key = db + "." + collection; - CollectionIndexStore existing = indexStoreByCollection.get(key); + InMemTransactionContext ctx = currentTransaction.get(); + // The provenance this caller requires: its own transaction, or "live" outside one. + Object requiredOwner = ctx == null ? NO_TRANSACTION : ctx; + OwnedIndexStore existing = indexStoreByCollection.get(key); + if (existing != null) { + // A store built before the currently open transaction started is stale: it was + // built by reading through getCollection()/getDB(), which resolves against the LIVE + // database outside a transaction (see buildIndexStore/getDB) - so it holds live + // document instances. startTransaction() then clones the database for this + // transaction's writes to mutate in place, but never told this pre-existing store, + // which keeps serving those now-superseded live instances for the rest of the + // transaction. Index-backed reads inside the transaction see stale data (diverging + // from a full scan, which does resolve against the transaction's snapshot), and any + // update whose candidate came from an index-backed lookup mutates a live object the + // commit never merges back - the write is lost. + // + // Reuse is only safe when the store was built from the same data this caller reads + // through. There are three provenances and, outside a transaction, only one of them + // qualifies: + // + // - NO_TRANSACTION: built from the live database. Valid for a non-transactional + // caller, stale for a transaction (that transaction's writes go to its clones, + // which this store never learns about - the bug this fix exists for). + // - the CALLER's own context: built from exactly the snapshot this caller writes to + // and reads through. Valid for that transaction, and unreachable here for a + // non-transactional caller. + // - SOME OTHER transaction's context: built from a different, possibly still-open + // snapshot holding that transaction's uncommitted clones. Never valid for anyone + // else - a non-transactional reader would observe uncommitted data, and another + // transaction's index-backed update would land in the wrong snapshot, lost on its + // own commit and corrupting the other's on the way. + // + // currentTransaction is thread-local, so transactions genuinely overlap across + // threads (see InMemTransactionIsolationTest) and all three provenances really do + // occur. Build ORDER cannot separate them - a later build may well belong to someone + // else - which is why this is keyed by context identity. + if (existing.owner() == requiredOwner) { + return existing.store(); + } + // Stale (predates this transaction) or foreign (belongs to a different, still-open + // transaction on another thread): fall through to a rebuild, exactly like a cache + // miss. The mismatching entry is deliberately NOT removed here - it instead changes + // owner atomically once the rebuild below finishes, via a compare-and-swap keyed on + // the exact entry we just saw. Removing it looks tidier but buys nothing and costs a + // lot: any other caller applies this same provenance check and would reject the + // entry anyway, and the CAS below already copes with someone else's entry occupying + // the key. What removal did buy was a rebuild ping-pong - each side throwing the + // other's store away on every single access, so a collection with an open + // transaction and interleaved transactional / non-transactional lookups rebuilt on + // every lookup instead of only on the mismatching side. Measured on a 5000-document + // collection with 20 operations inside a transaction that runs against a + // pre-existing store: 20 buildIndexStore passes with the removal, 1 without; a purely + // non-transactional caller (no transaction open at all) sees 0 either way. Same + // numbers for one secondary index and for two. Since buildIndexStore is + // O(documents x indexes), keeping the entry reachable for a same-owner swap is both + // cheaper and closer to the "cost proportional to what a transaction actually + // touches" property this cache is supposed to have. A swap also never creates a + // "no entry present" window, unlike a remove-then-publish would, which matters + // because two callers reach this method without holding the collection lock (the + // ExplainCommand path in runCommand and recordAggregateSlowQueryIfNeeded) and could + // otherwise publish a store built from a document list another thread is + // concurrently mutating. + } + // Read BEFORE the documents snapshot inside buildIndexStore: if an invalidate or a + // DB-wide drop lands between here and the publish below, the snapshot may predate that + // mutation and must not be published (see indexStoreEpochByCollection, #290). + Long epochBefore = indexStoreEpochByCollection.get(key); + long dropEpochBefore = indexStoreDropEpoch.get(); + OwnedIndexStore built = new OwnedIndexStore(buildIndexStore(db, collection), requiredOwner); + if (indexStoreEpochMovedSince(key, epochBefore, dropEpochBefore)) { + // Concurrent invalidate during the build - the snapshot is possibly stale. Use it + // privately (same semantics as losing the publish race below: valid for this caller's + // one-shot read, invisible to everyone else) and let the next caller rebuild fresh. + // No transaction recording either: that tracks PUBLISHED stores a commit must purge. + return built.store(); + } + // Store and owner are published in a single map operation, so no other thread can ever + // see one without the other. If existing was null, this is a plain first-touch publish + // (putIfAbsent). If existing was non-null (the mismatch case above), the entry changes + // owner atomically via replace(key, existing, built) - a CAS keyed on the exact snapshot + // we read - rather than being removed and re-inserted, so there is never a moment with + // no entry for this key. Either way, if another thread won the race (replace failed, or + // putIfAbsent found someone already there), its entry only counts for us when its + // provenance matches ours - otherwise we must NOT return it (that was the whole point of + // the check above) and use our own build instead. Ours is not published in that case: the + // winner's entry stays, and the next caller re-evaluates provenance normally. Building + // twice is wasteful but never incorrect (see this method's contract), whereas handing + // back a foreign snapshot's store is exactly the cross-transaction leak this guards + // against. + OwnedIndexStore prev; if (existing != null) { - return existing; + prev = indexStoreByCollection.replace(key, existing, built) ? null : indexStoreByCollection.get(key); + } else { + prev = indexStoreByCollection.putIfAbsent(key, built); + } + if (prev != null) { + return prev.owner() == requiredOwner ? prev.store() : built.store(); + } + // Post-publish re-validation: the pre-build check above is check-then-act, so a full + // invalidate (bump + remove) can land entirely between it and the publish - the publish + // then resurrects the possibly-stale snapshot right after the invalidate's remove. If the + // epoch moved, undo exactly our own entry (conditional remove - OwnedIndexStore compares + // by store identity, so someone else's newer publish is never touched) and fall back to + // private use. Combined with invalidateIndexStore's bump-before-remove ordering this + // closes the race completely: a publish that slips past this check happened before the + // bump, and therefore before the remove that discards it (#290). + if (indexStoreEpochMovedSince(key, epochBefore, dropEpochBefore)) { + indexStoreByCollection.remove(key, built); + return built.store(); + } + // Record that this collection's persistent index store was actually BUILT (not merely + // reused) while a transaction is open - see + // InMemTransactionContext#getIndexStoreAccessedCollections. Only a build reads via + // getCollection(), which resolves against this transaction's private (cloned) snapshot + // while one is active - i.e. against structurally-cloned document instances, not the + // live ones - so only a build can seed the store with clones that must not outlive the + // transaction. A plain reuse of an already-built store can never introduce clones: the + // identity check above only reuses a store this very transaction built, i.e. one whose + // clones are the ones this transaction is already working on. Write paths are covered + // separately and unconditionally by markCollectionTouched before their first store + // mutation, so they need no recording here even though they also call this method. + if (ctx != null) { + ctx.getIndexStoreAccessedCollections().add(db + "/" + collection); } - CollectionIndexStore built = buildIndexStore(db, collection); - CollectionIndexStore prev = indexStoreByCollection.putIfAbsent(key, built); - return prev != null ? prev : built; + return built.store(); } - private CollectionIndexStore buildIndexStore(String db, String collection) throws MorphiumDriverException { + /* package-private: same-package tests override this to interleave a concurrent write with the + * publish in getIndexStore (#290) */ + CollectionIndexStore buildIndexStore(String db, String collection) throws MorphiumDriverException { indexStoreRebuilds++; CollectionIndexStore store = new CollectionIndexStore(); List> indexDescriptors = getIndexes(db, collection); @@ -5849,8 +6409,25 @@ private static boolean isDefaultIdDefinition(IndexDefinition def) { * collection's document list wholesale (see each call site's own comment for why a full rebuild * is the right, and cheap-enough, answer there). */ + /** + * True when {@code db.collection}'s store was invalidated (per-key epoch) or ANY store was + * dropped wholesale (drop epoch) since the caller sampled both values before its + * {@link #buildIndexStore} snapshot - i.e. when that snapshot may predate a concurrent + * mutation and must not be published (#290). + */ + private boolean indexStoreEpochMovedSince(String key, Long epochBefore, long dropEpochBefore) { + return !java.util.Objects.equals(epochBefore, indexStoreEpochByCollection.get(key)) + || dropEpochBefore != indexStoreDropEpoch.get(); + } + private void invalidateIndexStore(String db, String collection) { - indexStoreByCollection.remove(db + "." + collection); + String key = db + "." + collection; + // Bump BEFORE the remove: a lock-free builder (see getIndexStore, #290) re-reads this + // epoch at publish time, so with this ordering its publish either happens after the bump + // (epoch check refuses it) or before it (this remove discards it) - a pre-mutation + // snapshot can never outlive this invalidate either way. + indexStoreEpochByCollection.merge(key, 1L, Long::sum); + indexStoreByCollection.remove(key); } /** @@ -6001,7 +6578,10 @@ private static Boolean sortScanDirection(IndexDefinition def, Map> getDataFromIndex(String db, String collection, Map query) throws MorphiumDriverException { CollectionIndexStore store = getIndexStore(db, collection); - Collection defs = store.definitions(); + // planningDefinitions(), not definitions(): a multikey index holds each array as ONE key, + // so a scalar lookup key never matches and the prefilter would come back empty - which + // the contract above then takes as the authoritative (empty) result (#289). + Collection defs = store.planningDefinitions(); if (defs.size() <= 1) { return null; // only the default _id index exists - never worth planning } @@ -6135,6 +6715,39 @@ public List> insert(String db, String collection, List> objs, List origIdx, + List positions) { + for (int i = positions.size() - 1; i >= 0; i--) { + int p = positions.get(i); + objs.remove(p); + origIdx.remove(p); + } + } + + /** + * Number of documents an insert actually committed, derived from its writeErrors. + * batchSize - writeErrors.size() is only right for unordered inserts; an ordered insert + * stops at the first error, so everything after it was never attempted - the first error's + * (original-batch) index IS the number of documents inserted before it. + */ + public static int insertedCountFromWriteErrors(int batchSize, boolean ordered, List> writeErrors) { + if (writeErrors == null || writeErrors.isEmpty()) { + return batchSize; + } + + if (ordered) { + return ((Number) writeErrors.get(0).get("index")).intValue(); + } + + return batchSize - writeErrors.size(); + } + @SuppressWarnings({"unchecked", "rawtypes"}) public List> insert(String db, String collection, List> objs, Map wc, boolean ordered) throws MorphiumDriverException { @@ -6147,10 +6760,19 @@ public List> insert(String db, String collection, List(objs); writeErrors = new ArrayList<>(); + // Original batch position of each working-list entry, kept aligned with objs across + // every removal below. writeErrors.index must refer to the CLIENT's batch - indexing + // into the shrunken working list silently shifted every error reported after an + // earlier loop had already removed a document. + List origIdx = new ArrayList<>(objs.size()); + + for (int i = 0; i < objs.size(); i++) { + origIdx.add(i); + } // BSON size gate (mongod parity, code 10334) - like the duplicate checks below: // ordered inserts throw, unordered ones report a per-document writeError - List> oversized = new ArrayList<>(); + List oversizedPos = new ArrayList<>(); for (int objIdx = 0; objIdx < objs.size(); objIdx++) { MorphiumDriverException tooBig = documentTooLarge(objs.get(objIdx), false); @@ -6160,100 +6782,57 @@ public List> insert(String db, String collection, List> indexes = getIndexes(db, collection); - if (indexes != null && !indexes.isEmpty()) { - for (var idx : indexes) { - if (idx.containsKey("$options")) { - Map options = (Map) idx.get("$options"); - - if (options.containsKey("unique") - && (options.get("unique").equals("true") || options.get("unique").equals(true))) { - // checking fields - Map indexKey = new HashMap<>(idx); - List> duplicateDocs = new ArrayList<>(); + removeWorkingListPositions(objs, origIdx, oversizedPos); + // NO unique-index pre-check here anymore: CollectionIndexStore.onInsert (the loop + // further down) is the single authority for uniqueness - committed-document conflicts + // and intra-batch conflicts alike surface there as per-doc writeErrors, with mongod's + // actual ordered semantics (stop at the first error) instead of the removed legacy + // O(collection)-scan's skip-and-continue. The scan also re-implemented the index + // membership rules (sparse/partialFilterExpression) separately from the store and got + // the partial-filter half wrong: it exempted only the INCOMING document, so an + // uncovered stored document still counted as a collision partner - a false E11000 + // mongod does not raise (review 2026-08-13). - for (int objIdx = 0; objIdx < objs.size(); objIdx++) { - var o = objs.get(objIdx); - var q = Doc.of(); - - for (String k : indexKey.keySet()) { - if (k.startsWith("$")) { - continue; - } - - q.put(k, o.get(k)); - } - - if (q.size() != 1) { - // need to add and query - List> and = new ArrayList(); - for (var e : q.entrySet()) { - and .add(Doc.of(e.getKey(), e.getValue())); - } - q = Doc.of("$and", and ); - } - - if (existsMatchingDocument(db, collection, q)) { - log.error("Cannot store - unique index!"); - writeErrors.add(Doc.of( - "index", objIdx, - "code", 11000, - "errmsg", "E11000 duplicate key error" - )); - duplicateDocs.add(o); - } - } - - errors = errors + duplicateDocs.size(); - objs.removeAll(duplicateDocs); - } - } - } - } - - // Get collection once and create snapshot for duplicate checking + // Get collection once - used for capped eviction and the physical adds below var collectionData = getCollection(db, collection); // Fetch/build the persistent index store BEFORE any mutation of collectionData below - // see getIndexStore's lifecycle contract: a first-touch build must see the pre-insert // document list, or the later onInsert calls would double-count the new docs. CollectionIndexStore indexStore = getIndexStore(db, collection); - // Build HashSet of existing _ids for O(1) lookup instead of O(N) nested loop - Set existingIds = new HashSet<>(); - for (Map existing : collectionData) { - Object id = existing.get("_id"); - if (id != null) { - existingIds.add(id); - } - } - - // Check new objects for duplicates in O(M) time instead of O(N*M) - List> idDuplicates = new ArrayList<>(); + // Check new objects for duplicate _ids against the committed documents via the + // store's always-present unique _id_ index - an O(1) point lookup per document + // instead of building a HashSet over the WHOLE collection on every insert call + // (O(N) under the write lock, the dominant cost for single-document inserts into + // large collections, e.g. messaging). At this point the index reflects exactly the + // pre-insert document list (first-touch builds seed it via seedIdIndex, every write + // path maintains it incrementally), and this loop never adds to it - so duplicates + // BETWEEN documents of this same batch still only surface at onInsert below, + // exactly as with the old snapshot-based check. + List idDuplicatePos = new ArrayList<>(); for (int objIdx = 0; objIdx < objs.size(); objIdx++) { Map o = objs.get(objIdx); - if (o.get("_id") != null && existingIds.contains(o.get("_id"))) { + if (o.get("_id") != null && indexStore.containsId(o.get("_id"))) { if (ordered) { throw new MorphiumDriverException("Duplicate _id! " + o.get("_id"), null); } writeErrors.add(Doc.of( - "index", objIdx, + "index", origIdx.get(objIdx), "code", 11000, "errmsg", "E11000 duplicate key error collection: " + db + "." + collection + " dup key: { _id: " + o.get("_id") + " }" )); - idDuplicates.add(o); + idDuplicatePos.add(objIdx); continue; } o.putIfAbsent("_id", new ObjectId()); } - objs.removeAll(idDuplicates); + removeWorkingListPositions(objs, origIdx, idDuplicatePos); // collectionData already retrieved above // Capped collections may evict existing documents to make room for the incoming // batch - those evictions must be reflected in the index store too, or evicted docs @@ -6294,6 +6873,7 @@ public List> insert(String db, String collection, List cappedInfo.get("max")) { objs.remove(0); + origIdx.remove(0); } } // The old byte-capped trim of the incoming batch compared against @@ -6330,7 +6910,7 @@ public List> insert(String db, String collection, List> insert(String db, String collection, List buildChangeStreamEvent -> shallowCopyAndNormalizeDocument - // creates a shallow copy (sufficient because doc values are not mutated in-place). + // notifyWatchers -> buildChangeStreamEvent -> deepCopyAndNormalizeDocument + // deep-copies each document, so events stay stable even when a later update + // mutates the stored document in place. for (Map o : objs) { notifyWatchers(db, collection, "insert", o); } @@ -6380,6 +6961,8 @@ private static class PendingNotification { final Map updatedFields; final List removedFields; final Map beforeDocument; + /** See {@link #notifyWatchers(String, String, String, Map, Map, List, Map, boolean)}. */ + final boolean beforeDocumentIsExclusiveCopy; PendingNotification(String db, String collection, String op, Map doc) { this(db, collection, op, doc, null, null, null); @@ -6387,6 +6970,12 @@ private static class PendingNotification { PendingNotification(String db, String collection, String op, Map doc, Map updatedFields, List removedFields, Map beforeDocument) { + this(db, collection, op, doc, updatedFields, removedFields, beforeDocument, false); + } + + PendingNotification(String db, String collection, String op, Map doc, + Map updatedFields, List removedFields, Map beforeDocument, + boolean beforeDocumentIsExclusiveCopy) { this.db = db; this.collection = collection; this.op = op; @@ -6394,6 +6983,7 @@ private static class PendingNotification { this.updatedFields = updatedFields; this.removedFields = removedFields; this.beforeDocument = beforeDocument; + this.beforeDocumentIsExclusiveCopy = beforeDocumentIsExclusiveCopy; } } @@ -6412,8 +7002,7 @@ public Map store(String db, String collection, List storeInternal(String db, String collection, List previous = srch.get(0); getCollection(db, collection).remove(previous); // "o" is a brand new Map instance, not the same live reference as "previous" - @@ -6476,7 +7065,11 @@ private Map storeInternal(String db, String collection, List update(String db, String collection, Map doc, String path) { return cur; } + /** + * True when {@code path} resolves to a key/index that EXISTS but holds an explicit + * {@code null} - as opposed to not existing at all, which is what a plain + * {@link #getByPathArrayAware} {@code == null} cannot distinguish. The array-mutation + * operators need the distinction for mongod parity (#291): a missing field gets the array + * created, an explicitly-null field is a non-array value and must be rejected. + */ + @SuppressWarnings("rawtypes") + private boolean isExplicitNullAtPath(Map doc, String path) { + String[] parts = path.split("\\."); + Object cur = doc; + + for (int i = 0; i < parts.length - 1; i++) { + String p = parts[i]; + + if (cur instanceof Map) { + cur = ((Map) cur).get(p); + } else if (cur instanceof List && isArrayIndex(p)) { + List l = (List) cur; + int idx = Integer.parseInt(p); + cur = idx < l.size() ? l.get(idx) : null; + } else { + return false; + } + + if (cur == null) { + return false; + } + } + + String last = parts[parts.length - 1]; + + if (cur instanceof Map) { + return ((Map) cur).containsKey(last) && ((Map) cur).get(last) == null; + } + + if (cur instanceof List && isArrayIndex(last)) { + List l = (List) cur; + int idx = Integer.parseInt(last); + return idx < l.size() && l.get(idx) == null; + } + + return false; + } + /** * Path write that descends into arrays via numeric segments (padding with nulls like * MongoDB) and creates intermediate documents where the path does not exist yet. @@ -7625,10 +8262,20 @@ private Map updateInternal(String db, String collection, Map original; + // True only for the deepClone branch below, and only there: that clone shares no + // structure at all with the live document, and after the notification is queued + // nothing in this method reads or mutates it again - so the change-stream path can + // adopt it as the event's before-image instead of deep-copying it a second time + // (issue #274). Deliberately false for both buildPartialBeforeImage branches (they + // share untouched nested containers with the live document, see that method's + // javadoc) and for the shallow-copy fallback. + boolean originalIsExclusiveDeepCopy = false; if (needsFullBeforeImage) { original = deepClone(obj); if (original == null) { original = new HashMap<>(obj); // fallback + } else { + originalIsExclusiveDeepCopy = true; } fullBeforeImageCloneCount++; } else if (isReplacement) { @@ -7689,6 +8336,21 @@ private Map updateInternal(String db, String collection, Map updateInternal(String db, String collection, Map(); setByPathArrayAware(obj, field, v); created = true; @@ -8103,6 +8774,10 @@ private Map updateInternal(String db, String collection, Map(); obj.put(field, v); created = true; @@ -8322,13 +8997,13 @@ private Map updateInternal(String db, String collection, Map shallowCopyAndNormalizeDocument - // will create its own shallow copy for the change stream event + // These two only read "original"; queuing the notification below is its last + // use here, which is what lets the change-stream path take it over verbatim + // when it is a full deepClone (originalIsExclusiveDeepCopy - issue #274). Map updatedMap = computeUpdatedFields(original, obj); List removedList = computeRemovedFields(original, obj); pendingNotifications.add(new PendingNotification(db, collection, "update", obj, updatedMap, - removedList, original)); + removedList, original, originalIsExclusiveDeepCopy)); } } if (insert) { @@ -8360,6 +9035,13 @@ private void notifyWatchers(String db, String collection, String op, Map doc, Ma notifyWatchers(db, collection, op, doc, updatedFields, removedFields, null); } + /** Drains one deferred notification - see {@link PendingNotification}. */ + private void notifyWatchers(PendingNotification notification) { + notifyWatchers(notification.db, notification.collection, notification.op, notification.doc, + notification.updatedFields, notification.removedFields, notification.beforeDocument, + notification.beforeDocumentIsExclusiveCopy); + } + /** * { * _id : { }, @@ -8388,6 +9070,30 @@ private void notifyWatchers(String db, String collection, String op, Map doc, Ma */ private void notifyWatchers(String db, String collection, String op, Map doc, Map updatedFields, List removedFields, Map beforeDocument) { + notifyWatchers(db, collection, op, doc, updatedFields, removedFields, beforeDocument, false); + } + + /** + * @param beforeDocumentIsExclusiveCopy {@code true} promises that {@code beforeDocument} is + * already a fully independent deep copy (no structure shared with any live stored + * document) whose ownership the caller hands over here for good - it neither reads nor + * mutates it afterwards. Only then may {@link #buildChangeStreamEvent} adopt the map as + * the event's before-image instead of deep-copying it a second time (issue #274). Pass + * {@code false} - the default of every other overload - whenever {@code beforeDocument} + * is a live reference, aliases {@code doc}, or is a + * {@link #buildPartialBeforeImage} result that still shares nested containers with the + * stored document. This says nothing about {@code doc}: the after-image is a live, + * in-place-mutated document on every path and is always deep-copied. + */ + private void notifyWatchers(String db, String collection, String op, Map doc, Map updatedFields, + List removedFields, Map beforeDocument, + boolean beforeDocumentIsExclusiveCopy) { + // Writes inside a suppressChangeStreamEvents() scope (replication initial sync: wipe + + // snapshot copy) are never observable via the change stream - neither recorded into the + // history nor dispatched to live subscribers. See the scope's javadoc for why. + if (Boolean.TRUE.equals(changeStreamSuppressed.get())) { + return; + } // Build and dispatch change stream event synchronously // This method is now called AFTER write locks are released (see // insert/store/update methods) @@ -8396,14 +9102,15 @@ private void notifyWatchers(String db, String collection, String op, Map doc, Ma // // Note: "after lock release" means the sequence token below is NOT assigned under // the collection lock, so two racing writers can get tokens in the opposite of - // their store order. For admin.system.users writes that inversion is corrected by - // userWriteEmitLock (held across store+notify in createUserInternal / + // their store order. For createUser/updateUser racing EACH OTHER that inversion is + // corrected by userWriteEmitLock (held across store+notify in createUserInternal / // updateUserInternal) because PoppyDB replicates users via this stream in token - // order - see the field's javadoc for the full reasoning. + // order - see the field's javadoc, including its SCOPE paragraph: raw deletes on + // admin.system.users and cross-namespace token inversion are NOT covered. // log.debug("notifyWatchers called: db={}, coll={}, op={}, driver instance={}", // db, collection, op, System.identityHashCode(this)); ChangeStreamEventInfo eventInfo = buildChangeStreamEvent(db, collection, op, doc, updatedFields, removedFields, - beforeDocument); + beforeDocument, beforeDocumentIsExclusiveCopy); if (eventInfo == null) { return; @@ -8426,13 +9133,41 @@ private void notifyWatchers(String db, String collection, String op, Map doc, Ma changeStreamHistory.addLast(eventInfo); changeStreamHistorySize.incrementAndGet(); + changeStreamHistoryBytes.addAndGet(eventInfo.estimatedBytes); - while (changeStreamHistorySize.get() > changeStreamHistoryLimit) { - if (changeStreamHistory.pollFirst() != null) { - changeStreamHistorySize.decrementAndGet(); - } else { + long budget = changeStreamHistoryByteBudget; + + // Evict oldest while either bound is exceeded. The just-appended (= newest) event is + // never evicted (size > 1 guard on the byte branch), so an event larger than the whole + // budget stays buffered as the only entry instead of looping forever. + while (true) { + boolean overCount = changeStreamHistorySize.get() > changeStreamHistoryLimit; + boolean overBytes = budget > 0 && changeStreamHistoryBytes.get() > budget + && changeStreamHistorySize.get() > 1; + + if (!overCount && !overBytes) { + break; + } + + ChangeStreamEventInfo evicted = changeStreamHistory.pollFirst(); + + if (evicted == null) { break; // deque already empty } + + changeStreamHistorySize.decrementAndGet(); + changeStreamHistoryBytes.addAndGet(-evicted.estimatedBytes); + + if (!overCount) { + changeStreamHistoryEvictedForBudget.incrementAndGet(); + long now = System.currentTimeMillis(); + + if (now - lastBudgetEvictWarnAt > 60_000) { + lastBudgetEvictWarnAt = now; + log.warn("Replay buffer byte budget ({} bytes) exceeded - evicting oldest change " + + "events; the resume window is shrinking (bulk writes of large documents?)", budget); + } + } } if (!hasSubscribers(db, collection)) { @@ -8446,9 +9181,17 @@ private void notifyWatchers(String db, String collection, String op, Map doc, Ma @SuppressWarnings("unchecked") private ChangeStreamEventInfo buildChangeStreamEvent(String db, String collection, String op, Map doc, - Map updatedFields, List removedFields, Map beforeDocument) { - Map newDocument = shallowCopyAndNormalizeDocument((Map) doc); - Map previousDocument = shallowCopyAndNormalizeDocument((Map) beforeDocument); + Map updatedFields, List removedFields, Map beforeDocument, + boolean beforeDocumentIsExclusiveCopy) { + // The after-image is ALWAYS the live, in-place-mutated stored document - it must be + // deep-copied, no exceptions (see deepCopyAndNormalizeDocument's javadoc and cf3e9cace). + Map newDocument = deepCopyAndNormalizeDocument((Map) doc); + // The before-image may already be an exclusively-owned deep copy the caller hands over - + // then the second recursive copy would be pure waste and only the _id normalization is + // still needed. See the parameter's contract on notifyWatchers. + Map previousDocument = beforeDocumentIsExclusiveCopy + ? normalizeDocumentIdInPlace((Map) beforeDocument) + : deepCopyAndNormalizeDocument((Map) beforeDocument); Map event = new LinkedHashMap<>(); long token = changeStreamSequence.incrementAndGet(); @@ -8551,11 +9294,13 @@ private void dispatchEvent(ChangeStreamEventInfo eventInfo) { // for replication and to provide backpressure. deliveryTask.run(); } else { - // In client mode, dispatch async via virtual threads. Synchronous delivery - // causes deadlocks in messaging: the callback processes messages which trigger - // further writes, blocking the original writer thread indefinitely. - // Virtual threads ensure no event is lost (no bounded queue) while keeping - // the writer thread free. + // In client mode, dispatch async on the single-threaded eventDispatcher. + // Synchronous delivery causes deadlocks in messaging: the callback processes + // messages which trigger further writes, blocking the original writer thread + // indefinitely. The single dispatcher thread with its unbounded queue keeps + // the writer free AND preserves submission order — mongod guarantees + // per-cursor ordering, and a pool would reorder under load (see the + // eventDispatcher field's javadoc / ChangeStreamEventOrderingTest). try { eventDispatcher.execute(deliveryTask); } catch (java.util.concurrent.RejectedExecutionException e) { @@ -8635,14 +9380,58 @@ public void setChangeStreamHistoryLimit(int limit) { } this.changeStreamHistoryLimit = limit; while (changeStreamHistorySize.get() > limit) { - if (changeStreamHistory.pollFirst() != null) { + ChangeStreamEventInfo evicted = changeStreamHistory.pollFirst(); + if (evicted != null) { changeStreamHistorySize.decrementAndGet(); + changeStreamHistoryBytes.addAndGet(-evicted.estimatedBytes); } else { break; // deque already empty } } } + /** + * Set the replay-buffer byte budget (estimated bytes, see {@link #estimateBsonSize}). 0 + * disables the byte bound (default - only the count limit applies). Shrinking the budget + * immediately trims the oldest buffered events down to the new bound; the newest event is + * always retained. Eviction semantics are identical to count overflow: a consumer whose + * resume token falls into the evicted range gets window-lost and must re-sync. + */ + public void setChangeStreamHistoryByteBudget(long bytes) { + if (bytes < 0) { + throw new IllegalArgumentException("changeStreamHistoryByteBudget must be >= 0 (0 = disabled)"); + } + + this.changeStreamHistoryByteBudget = bytes; + + while (bytes > 0 && changeStreamHistoryBytes.get() > bytes && changeStreamHistorySize.get() > 1) { + ChangeStreamEventInfo evicted = changeStreamHistory.pollFirst(); + + if (evicted == null) { + break; // deque already empty + } + + changeStreamHistorySize.decrementAndGet(); + changeStreamHistoryBytes.addAndGet(-evicted.estimatedBytes); + changeStreamHistoryEvictedForBudget.incrementAndGet(); + } + } + + /** Current replay-buffer byte budget; 0 = byte bound disabled. */ + public long getChangeStreamHistoryByteBudget() { + return changeStreamHistoryByteBudget; + } + + /** Estimated bytes currently held by the replay buffer (diagnostic). */ + public long getChangeStreamHistoryBytes() { + return changeStreamHistoryBytes.get(); + } + + /** Number of events currently held by the replay buffer (diagnostic). */ + public int getChangeStreamHistorySize() { + return changeStreamHistorySize.get(); + } + /** * Decide whether a change stream that has consumed up to {@code resumeToken} can be resumed * losslessly from the current replay buffer, i.e. whether every event after {@code resumeToken} @@ -8782,25 +9571,32 @@ private boolean hasSubscribers(String db, String collection) { } /** - * Creates a shallow copy of the document and normalizes the _id field. + * Creates a deep copy of the document and normalizes the _id field. *

- * A shallow copy is sufficient here because: - * 1. Primitive field values (String, Number, Boolean) are immutable. - * 2. The resulting event map is wrapped in Collections.unmodifiableMap() so - * subscribers cannot modify it. - * 3. Each subscriber's deliver() creates its own working copy (new HashMap<>(event)). - * 4. Nested Maps/Lists in documents are not mutated in-place by InMemoryDriver — - * updates replace the entire document in the collection. + * The copy MUST be deep - a shallow copy would share the stored document's nested + * Maps/Lists with the event, and those are NOT stable: + * 1. Update operators mutate live documents in place, including nested containers + * ($set on dotted paths writes into the existing nested Map/List, $push/$addToSet + * mutate the stored ArrayList itself, replacement updates clear()+putAll() the same + * Map instance) - see CollectionIndexStore's identity contract, which relies on + * exactly this. + * 2. Events outlive the write: they are appended to changeStreamHistory unconditionally + * (resume/replication replay) and dispatched asynchronously after the collection + * write lock is released, so a later update to the same document would retroactively + * corrupt archived events or race a concurrent serialization. *

- * This avoids the expensive recursive deepCopyDoc() that was previously called for - * every change stream event, even when no subscriber matches. + * Collections.unmodifiableMap() on the event and the subscribers' own working copies + * only protect the event's top level, not shared nested structures. A shallow-copy + * variant of this method was tried once and reverted the same day (cf3e9cace) - do not + * reintroduce it while the update paths mutate in place. */ - private Map shallowCopyAndNormalizeDocument(Map source) { + private Map deepCopyAndNormalizeDocument(Map source) { if (source == null) { return null; } - // Use deep copy to prevent shared mutable state between subscribers + // Deep copy to prevent shared mutable state between the live document, the event + // history, and subscribers Map copy = deepCopyDoc(source); if (copy.containsKey("_id")) { @@ -8810,6 +9606,28 @@ private Map shallowCopyAndNormalizeDocument(Map return copy; } + /** + * The copy-free half of {@link #deepCopyAndNormalizeDocument}: applies only the {@code _id} + * normalization and returns {@code source} itself. Reserved for a document whose ownership has + * been handed over to the change-stream path and which is already a fully independent deep copy + * - i.e. exactly the {@code beforeDocumentIsExclusiveCopy} contract on + * {@link #notifyWatchers(String, String, String, Map, Map, List, Map, boolean)}. Everything the + * deep copy protects against (in-place update operators, events outliving the write) is already + * ruled out for such a map, so copying it again would only duplicate work. Never call this for a + * live stored document. + */ + private Map normalizeDocumentIdInPlace(Map source) { + if (source == null) { + return null; + } + + if (source.containsKey("_id")) { + source.put("_id", normalizeId(source.get("_id"))); + } + + return source; + } + private Object extractDocumentKey(Map newDocument, Map previousDocument) { Object id = newDocument != null ? newDocument.get("_id") : null; @@ -8936,6 +9754,10 @@ private static final class ChangeStreamEventInfo { private final String collection; private final Map event; private final long createdAt; + // Estimated BSON-ish size of the full event map (including fullDocument), measured + // exactly once at construction - the byte-budget bookkeeping adds/subtracts this at + // every deque mutation site, so no separate size cache is needed. + private final long estimatedBytes; private ChangeStreamEventInfo(long token, String db, String collection, Map event, long createdAt) { @@ -8944,7 +9766,43 @@ private ChangeStreamEventInfo(long token, String db, String collection, Map m) { + long sum = 8; + for (Map.Entry e : m.entrySet()) { + sum += (e.getKey() instanceof String k ? k.length() + 2 : 8) + estimateBsonSize(e.getValue()); + } + return sum; + } + if (v instanceof Collection c) { + long sum = 8; + for (Object o : c) { + sum += 4 + estimateBsonSize(o); + } + return sum; } + return 16; // numbers, booleans, dates, ObjectIds, other scalars } private class ChangeStreamSubscription { @@ -9316,23 +10174,33 @@ public Map delete (String db, String collection, Map> getCollection(String db, String collection) throws MorphiumDriverException { Map>> dbMap = getDB(db); - if (!dbMap.containsKey(collection)) { - // Plain ArrayList storage: every mutation of this list happens under the collection's - // WRITE lock and every whole-list iteration happens under its READ lock (or over an - // explicit snapshot() taken under that read lock). This replaced CopyOnWriteArrayList, - // whose per-add array copy made single-doc inserts O(n) (O(n^2) to fill a collection); - // ArrayList.add is amortised O(1). Lock-free full-list iteration is therefore no longer - // safe - readers that used to rely on COW copy-on-iterate now go through snapshot(). - dbMap.put(collection, new ArrayList<>()); + // Plain ArrayList storage: every mutation of this list happens under the collection's + // WRITE lock and every whole-list iteration happens under its READ lock (or over an + // explicit snapshot() taken under that read lock). This replaced CopyOnWriteArrayList, + // whose per-add array copy made single-doc inserts O(n) (O(n^2) to fill a collection); + // ArrayList.add is amortised O(1). Lock-free full-list iteration is therefore no longer + // safe - readers that used to rely on COW copy-on-iterate now go through snapshot(). + // + // putIfAbsent, not containsKey-then-put: the old check-then-act let two threads racing on + // a not-yet-existing collection each install their OWN list, the later put orphaning a + // list another thread was already writing into under the collection lock. That is how two + // concurrent createUsers could both be told they had won (UserWriteEventsTest + // #createUserConcurrentlyExactlyOneWins). Locking at the call sites cannot fix it: several + // callers resolve a collection with no lock held at all, so the create must be atomic here. + // + // NOT computeIfAbsent: createIndex() below calls getCollection() again for the same key + // (deliberately - creating an index materializes the collection, like mongod), and + // ConcurrentHashMap forbids that recursive update, throwing IllegalStateException. + List> existing = dbMap.putIfAbsent(collection, new ArrayList<>()); + if (existing == null) { try { createIndex(db, collection, Doc.of("_id", 1), Doc.of("name", "_id_1")); } catch (MorphiumDriverException e) { @@ -9422,6 +10290,7 @@ public void drop(String db, String collection, WriteConcern wc) { changeStreamHistory.removeIf(e -> { if (db.equals(e.db) && collection.equals(e.collection)) { changeStreamHistorySize.decrementAndGet(); + changeStreamHistoryBytes.addAndGet(-e.estimatedBytes); return true; } return false; @@ -9438,6 +10307,7 @@ public void drop(String db, String collection, WriteConcern wc) { changeStreamHistory.removeIf(e -> { if (db.equals(e.db) && collection.equals(e.collection)) { changeStreamHistorySize.decrementAndGet(); + changeStreamHistoryBytes.addAndGet(-e.estimatedBytes); return true; } return false; @@ -9461,6 +10331,10 @@ public synchronized void drop(String db, WriteConcern wc) { } String dbPrefix = db + "."; + // Bump BEFORE the removal - same publish-fencing contract as invalidateIndexStore's + // bump-before-remove, but via the global drop epoch: a per-key bump could not cover + // collections whose store is only being built right now (#290). + indexStoreDropEpoch.incrementAndGet(); indexStoreByCollection.keySet().removeIf(key -> key.startsWith(dbPrefix)); long dropBoundary = changeStreamSequence.addAndGet(100); @@ -9469,6 +10343,7 @@ public synchronized void drop(String db, WriteConcern wc) { changeStreamHistory.removeIf(e -> { if (db.equals(e.db)) { changeStreamHistorySize.decrementAndGet(); + changeStreamHistoryBytes.addAndGet(-e.estimatedBytes); return true; } return false; @@ -9630,16 +10505,31 @@ private Map>> getIndexesForDB(String db) { } public List> getIndexes(String db, String collection) { - if (!getIndexesForDB(db).containsKey(collection)) { - // new collection, create default index for _id - // Use CopyOnWriteArrayList for thread-safe concurrent iteration and - // modification + // Same atomicity requirement as getCollection(): with a containsKey-then-put, two + // first-touches each installed their own list and the later put orphaned the other - + // an index descriptor added to the orphaned list is lost for good, so the index is + // never built and a unique constraint silently disappears. + // + // The default _id descriptor is seeded BEFORE publishing, not after. Publishing an + // empty list first is what the old code did, and it is not merely untidy: createIndex's + // "already present?" loop leaves found == true over an empty list, so a concurrent + // createIndex that observed the list in that state would silently drop the index it was + // asked to create. + // Use CopyOnWriteArrayList for thread-safe concurrent iteration and modification. + Map>> byCollection = getIndexesForDB(db); + List> existing = byCollection.get(collection); + + if (existing == null) { CopyOnWriteArrayList> value = new CopyOnWriteArrayList<>(); - getIndexesForDB(db).put(collection, value); value.add(Doc.of("_id", 1, "$options", Doc.of("name", "_id_1"))); + existing = byCollection.putIfAbsent(collection, value); + + if (existing == null) { + existing = value; + } } - return getIndexesForDB(db).get(collection); + return existing; } /** @@ -9759,8 +10649,7 @@ public Map findAndOneAndUpdate(String db, String col, Map findAndOneAndReplace(String db, String col, Map indexD if (fieldName != null) { Object expireVal = options.get("expireAfterSeconds"); int expireSeconds = (expireVal instanceof Number) ? ((Number) expireVal).intValue() : 0; - ttlInfo = new TtlIndexInfo(fieldName, expireSeconds); + Map ttlPartialFilter = + (options.get("partialFilterExpression") instanceof Map + && !((Map) options.get("partialFilterExpression")).isEmpty()) + ? (Map) options.get("partialFilterExpression") : null; + ttlInfo = new TtlIndexInfo(fieldName, expireSeconds, ttlPartialFilter); collectionsWithTtlIndex.put(db + "." + collection, ttlInfo); } } @@ -10409,10 +11301,100 @@ public void commitTransaction() { lock.writeLock().unlock(); } } + + // A read-only indexed query can lazily build a collection's persistent index store from + // THIS transaction's cloned snapshot (see getIndexStore) without ever writing to that + // collection, so it never appears in touchedCollections. That store must still be + // invalidated here - it may reference clone instances that must not outlive the + // transaction - even though there is no document list to merge back for it. + for (String key : ctx.getIndexStoreAccessedCollections()) { + if (ctx.getTouchedCollections().contains(key)) { + continue; // already invalidated above + } + invalidateIndexStoreForKey(key); + } } + /** + * Splits a {@code "db/collection"} key (as recorded in + * {@link InMemTransactionContext#getIndexStoreAccessedCollections}), takes that collection's + * write lock, and invalidates its persistent {@link CollectionIndexStore} and TTL expiry + * queue. Shared by {@link #commitTransaction}'s and {@link #abortTransaction}'s handling of + * index-store-accessed-but-not-written collections. Deliberately NOT used by + * {@code commitTransaction}'s {@code touchedCollections} loop above, which runs inside a + * lock already held for the document-list merge and needs that additional merge logic + * alongside the invalidation - folding it into this helper would change its semantics. + */ + private void invalidateIndexStoreForKey(String key) { + int sep = key.indexOf('/'); + String dbName = key.substring(0, sep); + String collName = key.substring(sep + 1); + java.util.concurrent.locks.ReadWriteLock lock = getCollectionLock(dbName, collName); + lock.writeLock().lock(); + try { + invalidateIndexStore(dbName, collName); + invalidateTtlQueue(dbName, collName); + } finally { + lock.writeLock().unlock(); + } + } + + /** + * Aborts the currently active in-memory transaction, discarding its private document + * snapshot. Every collection whose persistent {@link CollectionIndexStore} was actually + * built (not merely reused) while this transaction was open - not merely the ones it wrote + * to - must have that store invalidated here, mirroring {@link #commitTransaction}'s + * equivalent invalidation. + * + *

A store built (lazily, on first {@link #getIndexStore} access) WHILE the transaction was + * open is built from {@link #getCollection}, which resolves against the transaction's + * snapshot while one is active (see {@link #getDB}) - i.e. against structurally-cloned + * document instances ({@link #deepCloneDatabase} deep-copies every document). Those clone + * instances get registered into the store's unique-index buckets via + * {@link CollectionIndexStore#addIndex}/{@code onInsert}. This happens for a WRITE (insert, + * update, delete - all of which call {@link #markCollectionTouched}) but just as easily for a + * purely READ-ONLY indexed query ({@code getDataFromIndex}), which never touches + * {@code markCollectionTouched} at all - see + * {@link InMemTransactionContext#getIndexStoreAccessedCollections} for why that set, not + * {@link InMemTransactionContext#getTouchedCollections}, is the correct one to invalidate + * against here. + * + *

On abort, the snapshot itself is simply dropped - but the *store* is a single object + * shared across the live database and every transaction (keyed only by "db.collection", see + * {@link #indexStoreByCollection}), so without an explicit invalidation here it keeps + * referencing those now-orphaned clone instances. The real live documents that were never + * part of this aborted transaction (or that a subsequent commit/clear removed) then can never + * be found by {@link CollectionIndexStore.IndexEntry#remove}, which matches by reference + * identity - the clone is a different object from the live document, so removal silently + * no-ops and the bucket keeps "existing" forever. Every later duplicate-key check against + * that key then fails, even after the real live collection has been cleared to zero + * documents - see the bug this fixes: a unique-index key rejected a totally fresh insert, + * because onInsert() found a bucket seeded from a clone that outlived its aborted + * transaction. + * + *

This bounds the damage rather than eliminating every related race: it guarantees a + * clone can no longer outlive the transaction that created it. A narrower, pre-existing race + * remains out of scope - while a transaction is still OPEN (before commit or abort), a + * concurrent non-transactional thread that deletes and then re-inserts a live document under + * the same unique key can still collide with the transaction's clone and see a false + * duplicate. That race is not introduced by this fix and is not addressed here. + * + *

A separate, single-threaded variant of the general "identity-based staleness" problem + * class - a store built BEFORE the transaction even started, rather than one built during + * it and outliving it - is addressed by {@link #getIndexStore}'s provenance check, not here: + * such a store holds live document instances that this method's touchedCollections/ + * indexStoreAccessedCollections invalidation never sees, because it was never recorded as + * accessed by this (or any) transaction in the first place. + */ public void abortTransaction() { + InMemTransactionContext ctx = currentTransaction.get(); currentTransaction.set(null); + if (ctx == null) { + return; + } + for (String key : ctx.getIndexStoreAccessedCollections()) { + invalidateIndexStoreForKey(key); + } } public void setTransactionContext(MorphiumTransactionContext ctx) { diff --git a/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/IndexDefinition.java b/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/IndexDefinition.java index a23e10da3..24921cd4c 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/IndexDefinition.java +++ b/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/IndexDefinition.java @@ -25,16 +25,35 @@ public final class IndexDefinition { private final List fields; private final Map directions; private final boolean unique; + private final boolean sparse; + private final Map partialFilterExpression; + private final CompiledQuery compiledPartialFilter; private final Long expireAfterSeconds; private final String name; private IndexDefinition(List fields, Map directions, boolean unique, - Long expireAfterSeconds, String name) { + boolean sparse, Map partialFilterExpression, Long expireAfterSeconds, String name) { this.fields = fields; this.directions = directions; this.unique = unique; + this.sparse = sparse; + this.partialFilterExpression = partialFilterExpression; this.expireAfterSeconds = expireAfterSeconds; this.name = name; + // The filter is immutable and evaluated on every write of a partial index's collection - + // compile it ONCE here instead of going through QueryHelper.matchesQuery per document, + // whose global identity-keyed LRU takes a process-wide lock on every call (its own javadoc + // tells hot paths to compile). Falls back to null (interpreted evaluation) if this filter + // uses something the compiler cannot handle. + CompiledQuery compiled = null; + if (partialFilterExpression != null) { + try { + compiled = CompiledQuery.compile(partialFilterExpression); + } catch (RuntimeException e) { + compiled = null; + } + } + this.compiledPartialFilter = compiled; } /** @@ -64,6 +83,8 @@ public static IndexDefinition fromIndexMap(Map indexMap) { } boolean unique = false; + boolean sparse = false; + Map partialFilterExpression = null; Long expireAfterSeconds = null; String name = null; @@ -71,6 +92,15 @@ public static IndexDefinition fromIndexMap(Map indexMap) { Object uniqueOption = options.get("unique"); unique = Boolean.TRUE.equals(uniqueOption) || "true".equalsIgnoreCase(String.valueOf(uniqueOption)); + Object sparseOption = options.get("sparse"); + sparse = Boolean.TRUE.equals(sparseOption) || "true".equalsIgnoreCase(String.valueOf(sparseOption)); + + Object partialOption = options.get("partialFilterExpression"); + if (partialOption instanceof Map && !((Map) partialOption).isEmpty()) { + partialFilterExpression = Collections.unmodifiableMap( + new LinkedHashMap<>((Map) partialOption)); + } + Object expireOption = options.get("expireAfterSeconds"); if (expireOption instanceof Number) { expireAfterSeconds = ((Number) expireOption).longValue(); @@ -83,7 +113,8 @@ public static IndexDefinition fromIndexMap(Map indexMap) { } List orderedFields = Collections.unmodifiableList(new ArrayList<>(directions.keySet())); - return new IndexDefinition(orderedFields, directions, unique, expireAfterSeconds, name); + return new IndexDefinition(orderedFields, directions, unique, sparse, partialFilterExpression, + expireAfterSeconds, name); } /** @@ -111,6 +142,35 @@ public boolean unique() { return unique; } + /** + * Whether the index was declared {@code sparse}. The store still indexes every document + * (lookups must stay complete), but a sparse unique index skips its duplicate check + * for documents that contain none of the indexed fields - MongoDB excludes those documents + * from a sparse index entirely, so they never collide there. + */ + public boolean sparse() { + return sparse; + } + + /** + * The index's {@code partialFilterExpression}, or {@code null} if it has none. MongoDB leaves + * a document out of a partial index entirely when it does not match this query, so such a + * document can never collide on a unique index - see {@code CollectionIndexStore}, which is + * where that is enforced (this class only parses). + */ + public Map partialFilterExpression() { + return partialFilterExpression; + } + + /** + * The {@link #partialFilterExpression()} compiled once at construction, or {@code null} when + * there is no filter or it could not be compiled (callers then fall back to interpreted + * evaluation via {@code QueryHelper.matchesQuery}). + */ + public CompiledQuery compiledPartialFilter() { + return compiledPartialFilter; + } + /** TTL, in seconds, or {@code null} if this is not a TTL index. */ public Long expireAfterSeconds() { return expireAfterSeconds; @@ -124,6 +184,7 @@ public String name() { @Override public String toString() { return "IndexDefinition{fields=" + fields + ", directions=" + directions + ", unique=" + unique + + ", sparse=" + sparse + ", partialFilterExpression=" + partialFilterExpression + ", expireAfterSeconds=" + expireAfterSeconds + ", name=" + name + '}'; } } diff --git a/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/IndexKey.java b/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/IndexKey.java index 266eed4ba..945bc7922 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/IndexKey.java +++ b/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/IndexKey.java @@ -63,9 +63,11 @@ public String toString() { }; private final List values; + private final boolean containsList; - private IndexKey(List values) { + private IndexKey(List values, boolean containsList) { this.values = values; + this.containsList = containsList; } /** @@ -76,10 +78,40 @@ private IndexKey(List values) { */ public static IndexKey of(List values) { List normalized = new ArrayList<>(values.size()); + boolean containsList = false; for (Object v : values) { normalized.add(normalizeIdValue(v)); + containsList |= v instanceof List; } - return new IndexKey(Collections.unmodifiableList(normalized)); + return new IndexKey(Collections.unmodifiableList(normalized), containsList); + } + + /** + * True when every component of this key is the {@link #MISSING} sentinel - i.e. the source + * document contains none of the indexed fields. Sparse unique indexes skip their duplicate + * check for such keys (MongoDB excludes those documents from a sparse index entirely, so + * they can never collide there). + */ + public boolean allMissing() { + for (Object v : values) { + if (v != MISSING) { + return false; + } + } + return true; + } + + /** + * Whether the document this key was extracted from made the index multikey in MongoDB's + * sense: a field resolved to a {@code List} - either as the path's terminal value or as an + * array crossed mid-path (e.g. {@code "a.b"} over {@code {a:[{b:..}]}}). Since + * {@link #extract} neither expands terminal lists into one entry per element nor traverses + * mid-path arrays, no lookup key built from a scalar query value can ever match such a + * document - the index is unusable for lookups until real multikey support lands (#289). + * {@code CollectionIndexStore} uses this to mark an index and keep the planner off it. + */ + public boolean hasListValue() { + return containsList; } /** @@ -91,27 +123,38 @@ public static IndexKey of(List values) { * resolves to a {@code List}, that list itself becomes the extracted value, exactly as * MongoDB stores a scalar. A {@code List} encountered mid-path (e.g. {@code "a.b"} * where {@code a} is an array of sub-documents) is NOT traversed - the walk stops and the - * field extracts as {@link #MISSING}. Per-element multikey indexing (one index entry per - * array element) is out of scope here. + * field extracts as {@link #MISSING}, but the key still reports {@link #hasListValue()} so + * the index gets flagged multikey and the planner stays off it - mongod WOULD traverse the + * array and match per element, so serving lookups from this key would silently drop those + * documents (#289). Per-element multikey indexing (one index entry per array element) is out + * of scope here. * // Phase B follow-up: multikey indexes */ public static IndexKey extract(Map doc, IndexDefinition def) { List values = new ArrayList<>(def.fields().size()); + boolean[] sawMidPathList = new boolean[1]; + boolean containsList = false; for (String field : def.fields()) { - values.add(extractValue(doc, field)); + Object value = extractValue(doc, field, sawMidPathList); + values.add(value); + containsList |= value instanceof List; } - return new IndexKey(Collections.unmodifiableList(values)); + return new IndexKey(Collections.unmodifiableList(values), containsList || sawMidPathList[0]); } @SuppressWarnings("unchecked") - private static Object extractValue(Map doc, String path) { + private static Object extractValue(Map doc, String path, boolean[] sawMidPathList) { Object current = doc; for (String segment : path.split("\\.")) { if (!(current instanceof Map)) { // Either we walked off into a scalar, or hit a List along the path. // Phase B follow-up: multikey indexes - arrays would need to fan out into one - // index entry per element here; for now we just stop and treat it as missing. + // index entry per element here; for now we just stop and treat it as missing, + // recording the List so the index gets flagged multikey (#289). + if (current instanceof List) { + sawMidPathList[0] = true; + } return MISSING; } @@ -281,7 +324,8 @@ private static IndexKey buildPrefixBound(IndexDefinition def, List prefi values.add(useRawLow ? NEGATIVE_INFINITY : POSITIVE_INFINITY); } - return new IndexKey(Collections.unmodifiableList(values)); + // Synthetic range bounds never mark an index multikey - only real extracted keys do. + return new IndexKey(Collections.unmodifiableList(values), false); } @Override diff --git a/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/QueryHelper.java b/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/QueryHelper.java index b1d86dc14..1f443faf2 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/QueryHelper.java +++ b/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/QueryHelper.java @@ -1469,6 +1469,10 @@ static boolean matchesFieldCondition(String keyQuery, for (Object candidate : lookup.values) { if (candidate instanceof List) { + if (expected instanceof List && listEquals((List) candidate, (List) expected, coll)) { + return true; + } + for (Object element : (List) candidate) { if (compareValues(element, expected, coll)) { return true; @@ -1516,6 +1520,10 @@ static boolean matchesFieldCondition(String keyQuery, return false; } } + if (qv instanceof List + && listEquals(lst, (List) qv, collation != null ? getCollator(collation) : null)) { + return true; + } return lst.contains(qv); } @@ -2537,6 +2545,11 @@ static LookupResult resolveValuesForPath(Object current, String[] path, int posi result.values.add(element); } + // The array itself is a match candidate too, not only its elements: + // {path: [..]} must support whole-array equality (and {path: []} would + // otherwise contribute no candidates at all). Only the literal-equality + // branches consume values; $exists only reads pathExists. + result.values.add(current); return result; } @@ -2664,6 +2677,27 @@ static boolean compareValues(Object left, Object right, Collator coll) { return normalizedLeft.equals(normalizedRight); } + /** + * MongoDB whole-array equality: a literal query {@code {field: [..]}} matches a document + * whose array IS equal to the operand (order-sensitive, element count equal) — in addition + * to the multikey "array contains the operand as an element" case the callers handle. + * Elements are compared via {@link #compareValues} so id and numeric-type normalization + * stay consistent with scalar equality ([1, 2] matches [1L, 2.0]). + */ + static boolean listEquals(List docList, List expected, Collator coll) { + if (docList.size() != expected.size()) { + return false; + } + + for (int i = 0; i < docList.size(); i++) { + if (!compareValues(docList.get(i), expected.get(i), coll)) { + return false; + } + } + + return true; + } + static Object normalizeId(Object value) { if (value instanceof MorphiumId || value instanceof ObjectId) { return value == null ? null : value.toString(); @@ -3309,9 +3343,31 @@ public static Collator getCollator(Map collation) { } if (collation.containsKey("strength")) { - coll.setStrength((Integer) collation.get("strength")); + coll.setStrength(mapMongoStrength((Integer) collation.get("strength"))); } return coll; } + + /** + * MongoDB collation strength is 1-5 (primary..identical), {@link Collator} strength is 0-3 + * (PRIMARY..IDENTICAL). Passed through unmapped, every level shifted by one and 4/5 threw + * IllegalArgumentException. Java has no quaternary level, so 4 and 5 both map to IDENTICAL - + * the closest level at least as strong as what mongo promises. + */ + private static int mapMongoStrength(int mongoStrength) { + switch (mongoStrength) { + case 1: + return Collator.PRIMARY; + case 2: + return Collator.SECONDARY; + case 3: + return Collator.TERTIARY; + case 4: + case 5: + return Collator.IDENTICAL; + default: + throw new IllegalArgumentException("Invalid collation strength: " + mongoStrength + " (must be 1-5)"); + } + } } diff --git a/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/auth/UserDocuments.java b/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/auth/UserDocuments.java index cee55f8a1..3aeed683f 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/auth/UserDocuments.java +++ b/morphium-core/src/main/java/de/caluga/morphium/driver/inmem/auth/UserDocuments.java @@ -97,6 +97,10 @@ public static String validateCreateUser(Map cmd) { if (!(cmd.get("roles") instanceof List)) { return "roles must be an array"; } + Object customData = cmd.get("customData"); + if (customData != null && !(customData instanceof Map)) { + return "customData must be a document"; + } Object mechanisms = cmd.get("mechanisms"); if (mechanisms != null) { if (!(mechanisms instanceof List)) { diff --git a/morphium-core/src/main/java/de/caluga/morphium/driver/wire/BulkContext.java b/morphium-core/src/main/java/de/caluga/morphium/driver/wire/BulkContext.java deleted file mode 100644 index a55d49f05..000000000 --- a/morphium-core/src/main/java/de/caluga/morphium/driver/wire/BulkContext.java +++ /dev/null @@ -1,151 +0,0 @@ -package de.caluga.morphium.driver.wire; - -import de.caluga.morphium.Morphium; -import de.caluga.morphium.driver.Doc; -import de.caluga.morphium.driver.MorphiumDriverException; -import de.caluga.morphium.driver.WriteConcern; -import de.caluga.morphium.driver.bulk.*; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * User: Stephan Bösebeck - * Date: 06.12.15 - * Time: 23:14 - *

- * Bulk Context implementation for the singleconnect drivers - */ -@SuppressWarnings("WeakerAccess") -public class BulkContext extends BulkRequestContext { - private final DriverBase driver; - private final boolean ordered; - private final String db; - private final String collection; - private final WriteConcern wc; - - private final List requests; - - public BulkContext(Morphium m, String db, String collection, DriverBase driver, boolean ordered, int batchSize, WriteConcern wc) { - super(m); - this.driver = driver; - this.ordered = ordered; - this.db = db; - this.collection = collection; - this.wc = wc; - //setBatchSize(batchSize); - - requests = new ArrayList<>(); - } - - public void addRequest(BulkRequest br) { - requests.add(br); - } - - @Override - public UpdateBulkRequest addUpdateBulkRequest() { - UpdateBulkRequest up = new UpdateBulkRequest(); - addRequest(up); - return up; - } - - @Override - public InsertBulkRequest addInsertBulkRequest(List> toInsert) { - InsertBulkRequest in = new InsertBulkRequest(toInsert); - addRequest(in); - return in; - } - - - @Override - public DeleteBulkRequest addDeleteBulkRequest() { - DeleteBulkRequest del = new DeleteBulkRequest(); - addRequest(del); - return del; - } - - - @SuppressWarnings("StatementWithEmptyBody") - @Override - public Doc execute() throws MorphiumDriverException { - - - int count = 0; - @SuppressWarnings("MismatchedQueryAndUpdateOfCollection") List> results = new ArrayList<>(); - List> inserts = new ArrayList<>(); - List> stores = new ArrayList<>(); - List> updates = new ArrayList<>(); - - //TODO - add result data - for (BulkRequest br : requests) { - if (br instanceof InsertBulkRequest) { - // //Insert... - // InsertBulkRequest ib = (InsertBulkRequest) br; - inserts.addAll(((InsertBulkRequest) br).getToInsert()); - if (inserts.size() >= driver.getMaxWriteBatchSize()) { -// driver.insert(db, collection, inserts, wc); - inserts.clear(); - } - } else if (br instanceof DeleteBulkRequest) { - //no real bulk operation here -// driver.delete(db, collection, ((DeleteBulkRequest) br).getQuery(), new HashMap<>(),((DeleteBulkRequest) br).isMultiple(), null, wc); - } else { - // //update - UpdateBulkRequest up = (UpdateBulkRequest) br; - Map cmd = new HashMap<>(); - cmd.put("q", up.getQuery()); - cmd.put("u", up.getCmd()); - cmd.put("upsert", up.isUpsert()); - cmd.put("multi", up.isMultiple()); - updates.add(cmd); - if (updates.size() >= driver.getMaxWriteBatchSize()) { -// driver.update(db, collection, updates, ordered, wc); - updates.clear(); - } - } - count++; - } -// if (!inserts.isEmpty()) { -// driver.insert(db, collection, inserts, wc); -// } -// -// if (!stores.isEmpty()) { -// driver.store(db, collection, stores, wc); -// } - - if (!updates.isEmpty()) { - Map result = null; - //noinspection UnusedAssignment -// result = driver.update(db, collection, updates, ordered, wc); - } - - // - // - Map res = new HashMap<>(); - // - int delCount = 0; - @SuppressWarnings("UnusedAssignment") int matchedCount = 0; - @SuppressWarnings("UnusedAssignment") int insertCount = 0; - @SuppressWarnings("UnusedAssignment") int modifiedCount = 0; - @SuppressWarnings("UnusedAssignment") int upsertCount = 0; - for (Map r : results) { - //TODO - get metadata - // delCount += r.getDeletedCount(); - // matchedCount += r.getMatchedCount(); - // insertCount += r.getInsertedCount(); - // modifiedCount += r.getModifiedCount(); - // upsertCount += r.getUpserts().size(); - } - // - // res.put("num_del", delCount); - // res.put("num_matched", matchedCount); - // res.put("num_insert", insertCount); - // res.put("num_modified", modifiedCount); - // res.put("num_upserts", upsertCount); - // return res; - return null; - } - -} diff --git a/morphium-core/src/main/java/de/caluga/morphium/driver/wire/PooledDriver.java b/morphium-core/src/main/java/de/caluga/morphium/driver/wire/PooledDriver.java index 682cc0e7f..13e51d6ae 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/driver/wire/PooledDriver.java +++ b/morphium-core/src/main/java/de/caluga/morphium/driver/wire/PooledDriver.java @@ -1021,6 +1021,9 @@ private void createNewConnection(String hst) throws Exception { HelloResult result = con.connect(this, getHost(hst), getPortFromHost(hst)); stats.get(DriverStatsKey.CONNECTIONS_OPENED).incrementAndGet(); markStatsDirty(); + // A connect just succeeded - a caller polling isConnected()/getLastConnectFailure() + // after recovery must not keep seeing the pre-recovery error as if it were current. + lastConnectFailure = null; long dur = System.currentTimeMillis() - start; @@ -1324,12 +1327,17 @@ public MongoConnection getReadConnection(ReadPreference rp) { // recovered fine (observed live: readOk frozen for 25s+ while writeOk climbed). // borrowConnection() itself waits deadline-bounded (serverSelectionTimeout) for // the pool to be refilled, which is precisely what PRIMARY_PREFERRED wants. - if (primaryNode != null && hosts.get(primaryNode) != null) { + // Snapshot primaryNode: the heartbeat nulls the volatile field on stepdown or + // connection error - i.e. exactly while this failover-path code runs - and + // hosts.get(null) would throw an NPE that bypasses every MorphiumDriverException + // retry-catch on the read path. + String preferredPrimary = primaryNode; + if (preferredPrimary != null && hosts.get(preferredPrimary) != null) { try { - return borrowConnection(primaryNode); + return borrowConnection(preferredPrimary); } catch (MorphiumDriverException e) { stats.get(DriverStatsKey.ERRORS).incrementAndGet(); - log.warn("Could not get connection to {} trying secondary", primaryNode); + log.warn("Could not get connection to {} trying secondary", preferredPrimary); } } // fall through — primary not available or failed, try secondary @@ -1396,14 +1404,18 @@ case PingStats(var lastPing, var avgPing, var minPing, var maxPing, var count, v // this loop retrying the dead ex-primary while the healthy new primary // sat idle, so reads never recovered although writes did). Only a // strict SECONDARY preference keeps excluding the primary. - if (type != ReadPreferenceType.SECONDARY && retry > 0 && primaryNode != null - && hosts.get(primaryNode) != null) { + // Same snapshot rationale as the PRIMARY_PREFERRED branch above: the + // heartbeat nulls primaryNode concurrently, and hosts.get(null) NPEs + // past every retry-catch here. + String fallbackPrimary = primaryNode; + if (type != ReadPreferenceType.SECONDARY && retry > 0 && fallbackPrimary != null + && hosts.get(fallbackPrimary) != null) { try { - return borrowConnection(primaryNode); + return borrowConnection(fallbackPrimary); } catch (MorphiumDriverException pe) { stats.get(DriverStatsKey.ERRORS).incrementAndGet(); log.warn("Primary fallback failed too ({}) - continuing secondary retries", - primaryNode); + fallbackPrimary, pe); } } diff --git a/morphium-core/src/main/java/de/caluga/morphium/driver/wireprotocol/OpMsg.java b/morphium-core/src/main/java/de/caluga/morphium/driver/wireprotocol/OpMsg.java index 2cd00aece..658bb25a6 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/driver/wireprotocol/OpMsg.java +++ b/morphium-core/src/main/java/de/caluga/morphium/driver/wireprotocol/OpMsg.java @@ -66,6 +66,13 @@ public Map getFirstDoc() { return firstDoc; } + /** Kind-1 (document sequence) sections by their sequence identifier ("documents", + * "updates", "deletes"). Per wire spec each sequence is equivalent to a BSON array + * field of that name in the command body. Null if the message had none. */ + public Map>> getDocuments() { + return documents; + } + public OpMsg setFirstDoc(Map o) { firstDoc = o; return this; @@ -85,9 +92,13 @@ public OpMsg setFlags(int flags) { public void parsePayload(byte[] bytes, int offset) throws IOException { flags = readInt(bytes, offset); int idx = offset + 4; - int len = bytes.length; + // Payload end: when the wire-header size is known (setSize before parse), the payload is + // exactly size-16 bytes from offset. bytes.length is only correct for exact-size arrays — + // with a zero-copy backing array (PoppyDB's Netty decoder) the buffer can hold further + // pipelined messages (mongorestore does this), and parsing must not run into them. + int len = getSize() > 0 ? offset + getSize() - 16 : bytes.length; if ((getFlags() & CHECKSUM_PRESENT) != 0) { - len = bytes.length - 4; + len -= 4; } while (idx < len) { @@ -120,7 +131,7 @@ public void parsePayload(byte[] bytes, int offset) throws IOException { if ((getFlags() & CHECKSUM_PRESENT) != 0) { int crc = readInt(bytes, idx); CRC32C c = new CRC32C(); - c.update(bytes, 0, bytes.length - 4); + c.update(bytes, offset, len - offset); assert (crc == ((int) c.getValue())); } } @@ -139,7 +150,9 @@ public byte[] getPayload() throws IOException { sectionOut.write(BsonEncoder.encodeDocument(doc)); } byte[] section = sectionOut.toByteArray(); - writeInt(section.length, out); + out.write((byte) 1); // section kind 1: document sequence + writeInt(section.length + 4, out); // per spec the size includes its own 4 bytes + out.write(section); } } byte[] ret = out.toByteArray(); diff --git a/morphium-core/src/main/java/de/caluga/morphium/messaging/DualChannelMessaging.java b/morphium-core/src/main/java/de/caluga/morphium/messaging/DualChannelMessaging.java index b0ef6e8f9..2040e4bc0 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/messaging/DualChannelMessaging.java +++ b/morphium-core/src/main/java/de/caluga/morphium/messaging/DualChannelMessaging.java @@ -89,7 +89,11 @@ public class DualChannelMessaging extends Thread implements ShutdownListener, Mo private String hostname; private final Map pauseMessages = new ConcurrentHashMap<>(); - private Map> listenerByName = new HashMap<>(); + // Written only via clone-and-swap (never mutated in place) and declared volatile: the + // poll thread iterates the current map lock-free (rebuildMainCsIfFilterStale / + // buildMainCsPipeline), so in-place put/remove/clear would race that iteration. + private volatile Map> listenerByName = new HashMap<>(); + private ParticipantAnnouncer participantAnnouncer; private String queueName; private String lockCollectionName = null; private String collectionName = null; @@ -109,6 +113,9 @@ public class DualChannelMessaging extends Thread implements ShutdownListener, Mo private volatile long lastCsRestartMs = 0; private final AtomicLong csStallRestarts = new AtomicLong(0); private List> changeStreamPipeline; + // Topic snapshot the live main-CS pipeline was built with; compared against + // listenerByName.keySet() by the poll loop to detect a stale filter. + private volatile Set csFilterTopics = Set.of(); private int changeStreamMaxWait; // Throttles the main-thread-death log so we don't spam every poll cycle once detected. private volatile long lastMainThreadDeathLogMs = 0; @@ -298,9 +305,11 @@ public String getStatusInfoListenerName() { @Override public void setStatusInfoListenerName(String statusInfoListenerName) { - listenerByName.remove(this.statusInfoListenerName); + Map> c = new HashMap<>(listenerByName); + c.remove(this.statusInfoListenerName); this.statusInfoListenerName = statusInfoListenerName; - listenerByName.put(statusInfoListenerName, Arrays.asList(statusInfoListener)); + c.put(statusInfoListenerName, Arrays.asList(statusInfoListener)); + listenerByName = c; } @Override @@ -355,9 +364,13 @@ public void setStatusInfoListenerEnabled(boolean statusInfoListenerEnabled) { this.statusInfoListenerEnabled = statusInfoListenerEnabled; if (statusInfoListenerEnabled && !listenerByName.containsKey(statusInfoListenerName)) { - listenerByName.put(statusInfoListenerName, Arrays.asList(statusInfoListener)); + Map> c = new HashMap<>(listenerByName); + c.put(statusInfoListenerName, Arrays.asList(statusInfoListener)); + listenerByName = c; } else if (!statusInfoListenerEnabled) { - listenerByName.remove(statusInfoListenerName); + Map> c = new HashMap<>(listenerByName); + c.remove(statusInfoListenerName); + listenerByName = c; } } @@ -557,7 +570,7 @@ private boolean handleChangeStreamEvent(ChangeStreamEvent evt) { // First check if already in progress (most important for preventing duplicates) if (idsInProgress.contains(messageId)) { traceDecision(messageId, msg.get("in_answer_to"), "cs-event: already in idsInProgress, skipped"); - log.warn("CHANGESTREAM DUPLICATE CAUGHT: message {} already in idsInProgress", messageId); + log.debug("CHANGESTREAM DUPLICATE CAUGHT: message {} already in idsInProgress", messageId); return running; } @@ -590,7 +603,7 @@ private boolean handleChangeStreamEvent(ChangeStreamEvent evt) { log.debug("CSE: {}: Queued message {} for processing, queue size={}", id, messageId, processing.size()); } else { traceDecision(messageId, msg.get("in_answer_to"), "cs-event: already in processing queue, skipped"); - log.warn("CHANGESTREAM DUPLICATE CAUGHT: Message {} already in processing queue", messageId); + log.debug("CHANGESTREAM DUPLICATE CAUGHT: Message {} already in processing queue", messageId); } } } else { @@ -721,10 +734,47 @@ private void restartMainCsIfStalled(long stallThresholdMs) { log.warn("Main change stream for '{}' silent for {}ms while polling found backlog — restarting (restart #{})", getCollectionName(), silenceMs, csStallRestarts.incrementAndGet()); + replaceMainCsMonitor(old); + } + + /** + * Rebuild the main change stream when the registered topic set no longer matches the + * filter the live stream was built with (listener added/removed after the stream + * started — the topic clause in the server-side $match would otherwise silently drop + * broadcasts for newly registered topics, degrading them to fallback-poll latency). + * Runs on every poll tick; a no-op unless the topic set actually changed. Bursts of + * registrations therefore coalesce into a single rebuild. Delivery during the gap is + * covered by the poll (addListenerForTopic bumps requestPoll). + * + * Only call from the polling thread, same discipline as restartMainCsIfStalled(). + */ + private void rebuildMainCsIfFilterStale() { + if (!running || !useChangeStream) return; + if (changeStreamMonitor == null) return; + Set registered = Set.copyOf(listenerByName.keySet()); + if (registered.equals(csFilterTopics)) return; + + log.info("Topic set changed for '{}' ({} -> {}) — rebuilding main change stream filter", + getCollectionName(), csFilterTopics, registered); + changeStreamPipeline = buildMainCsPipeline(registered); + // Commit the snapshot only once the fresh monitor is actually up: a failed replace + // must leave csFilterTopics stale so the next poll tick retries the rebuild. + if (replaceMainCsMonitor(changeStreamMonitor)) { + csFilterTopics = registered; + } + } + + /** + * Terminate the given monitor and start a fresh one for the current + * changeStreamPipeline, rewired identically to the original. + * + * @return true if the fresh monitor is up, false if it could not be created/started + */ + private boolean replaceMainCsMonitor(ChangeStreamMonitor old) { try { old.terminate(); } catch (Exception e) { - log.warn("Error terminating stalled change stream for '{}': {}", getCollectionName(), e.getMessage()); + log.warn("Error terminating change stream for '{}': {}", getCollectionName(), e.getMessage()); } try { @@ -738,11 +788,21 @@ private void restartMainCsIfStalled(long stallThresholdMs) { // Reset markers — give the fresh stream the full threshold before re-evaluating. lastCsEventMs = System.currentTimeMillis(); lastCsRestartMs = lastCsEventMs; + return true; } catch (Exception e) { log.error("Failed to restart change stream for '{}'", getCollectionName(), e); + return false; } } + /** + * @return snapshot of the topics the live main change stream filter was built with — + * diagnostic counterpart to getCsStallRestarts() + */ + public Set getCsFilterTopics() { + return csFilterTopics; + } + /** * @return number of times the main change stream watchdog has triggered a restart since startup */ @@ -785,7 +845,15 @@ private void checkMainThreadAlive() { getCollectionName()); } - private void initChangeStreams() { + /** + * Build the $match pipeline for the main change stream, filtered server-side to + * what THIS instance can actually process, for the given snapshot of registered + * topics. The CALLER commits that snapshot to csFilterTopics — and must only do so + * once a stream built from this pipeline is actually up, so a failed (re)build + * leaves the staleness check failing and gets retried (see + * rebuildMainCsIfFilterStale()). + */ + private List> buildMainCsPipeline(Set registered) { // pipeline for reducing incoming traffic List> pipeline = new ArrayList<>(); Map match = new LinkedHashMap<>(); @@ -807,17 +875,41 @@ private void initChangeStreams() { // fallback poll (~FALLBACK_POLL_INTERVAL × pause latency). // // This filter restricts inserts to messages that are actually for this instance: - // - sender != my id → don't echo my own inserts - // - recipients null/me → broadcast or addressed to me + // - sender != my id → don't echo my own inserts + // - recipients me → addressed to me (answers and DMs from legacy + // senders on the main collection pass regardless of topic) + // - recipients null + topic listened → broadcasts only for topics with a registered + // listener; everything else would be dropped client-side after a wasted wakeup, + // decode and processing-executor slot ("no listener for topic") // lock_released events are passed through unchanged (no fullDocument). // Use translated Mongo field names so the pipeline survives camelCase mapping changes. String senderField = "fullDocument." + morphium.getARHelper().getMongoFieldName(Msg.class, Msg.Fields.sender.name()); String recipientsField = "fullDocument." + morphium.getARHelper().getMongoFieldName(Msg.class, Msg.Fields.recipients.name()); + String topicField = "fullDocument." + morphium.getARHelper().getMongoFieldName(Msg.class, Msg.Fields.topic.name()); + String inAnswerToField = "fullDocument." + morphium.getARHelper().getMongoFieldName(Msg.class, Msg.Fields.inAnswerTo.name()); + // The status-info topic is always watched: registry discovery must keep working + // regardless of listener registration state (#283). NOT part of the staleness + // snapshot below - it never changes with the listener set. + Set watchedTopics = new HashSet<>(registered); + watchedTopics.add(statusInfoListenerName); + // V5-legacy senders store only "name" - "topic" does not exist on those documents, + // and postLoad() maps it far too late for a server-side filter. preStore() sets + // name = topic on every 6.x send, so this clause only ever rescues legacy documents. + String legacyTopicField = "fullDocument.name"; + Map broadcastRelevant = new LinkedHashMap<>(); + broadcastRelevant.put(recipientsField, null); + // Broadcast answers (inAnswerTo set, no recipients) target the requester's waiter + // whatever topic they carry - they bypass the topic clause (#283). + broadcastRelevant.put("$or", Arrays.asList( + UtilsMap.of(topicField, UtilsMap.of("$in", new ArrayList<>(watchedTopics))), + UtilsMap.of(legacyTopicField, UtilsMap.of("$in", new ArrayList<>(watchedTopics))), + UtilsMap.of(inAnswerToField, UtilsMap.of("$ne", null)) + )); Map insertRelevant = new LinkedHashMap<>(); insertRelevant.put("operationType", "insert"); insertRelevant.put(senderField, UtilsMap.of("$ne", id)); insertRelevant.put("$or", Arrays.asList( - UtilsMap.of(recipientsField, null), + broadcastRelevant, UtilsMap.of(recipientsField, id) )); // Requeue detection: clearing processedBy via a plain DB update makes a message @@ -835,19 +927,28 @@ private void initChangeStreams() { insertRelevant )); pipeline.add(UtilsMap.of("$match", relevanceMatch)); + return pipeline; + } + + private void initChangeStreams() { // Use longer maxWait for change streams to avoid constant network polling // Change streams are designed to block server-side; short timeouts waste CPU/network changeStreamMaxWait = Math.max(pause * 10, morphium.getConfig().connectionSettings().getMaxWaitTime()); + Set registered = Set.copyOf(listenerByName.keySet()); + List> pipeline = buildMainCsPipeline(registered); changeStreamPipeline = pipeline; ChangeStreamMonitor lockMonitor = new ChangeStreamMonitor(morphium, getLockCollectionName(), false, changeStreamMaxWait, List.of(Doc.of("$match", Doc.of("operationType", Doc.of("$eq", "delete"))))); lockChangeStreamMonitor = lockMonitor; lockMonitor.addListener(evt -> { - // some lock removed - if (morphium.createQueryFor(Msg.class, getCollectionName()).f("_id").eq(evt.getDocumentKey()).countAll() != 0) { - // log.info("Lock CSE"); - requestPoll.incrementAndGet(); - } + // Some lock removed - ask for a poll. Deliberately no query here (#286): this runs on + // the change-stream callback thread, so a countAll per lock-delete event blocks the + // stream itself, and it buys nothing. requestPoll is a counter the poll loop reads, + // zeroes and answers with ONE findMessages(), so M lock deletes in a burst coalesce + // into a single poll either way - the gate traded M synchronous queries on this + // thread against at most one query in the poll thread. findMessages() decides what is + // actually pending, which it queries for correctly regardless. + requestPoll.incrementAndGet(); return running; }); changeStreamMonitor = new ChangeStreamMonitor(morphium, getCollectionName(), false, changeStreamMaxWait, pipeline); @@ -855,6 +956,9 @@ private void initChangeStreams() { // On every watch (re-)establishment poll once: messages inserted while the stream // was down are invisible to the new stream unless a resume token was available. changeStreamMonitor.addWatchEstablishedListener(requestPoll::incrementAndGet); + // Monitor construction succeeded — a throw above propagates and aborts startup, so + // committing the filter snapshot here can never record a filter no stream was built for. + csFilterTopics = registered; // Same for lock releases: a lock deleted during a lock-monitor gap would otherwise // never trigger its re-poll for exclusive messages. lockMonitor.addWatchEstablishedListener(requestPoll::incrementAndGet); @@ -1275,10 +1379,18 @@ private void persistDmProcessedByMark(Msg msg) { cmd.setColl(dmColl).setDb(morphium.getDatabase()); cmd.addUpdate(idq.toQueryObject(), Doc.of("$addToSet", Doc.of(processedByFieldName, id)), null, false, false, null, null, null); - cmd.execute(); + Map ret = cmd.execute(); cmd.releaseConnection(); cmd = null; + + // legacy null-field shape surfaces as a write error on mongod (#291) + if (ret.get("writeErrors") != null) { + LegacyProcessedByRepair.repairNullField(morphium, dmColl, queryId, processedByFieldName, id); + } } catch (MorphiumDriverException e) { + if (LegacyProcessedByRepair.repairNullField(morphium, dmColl, queryId, processedByFieldName, id)) { + return; + } log.error("Error persisting processed_by mark for DM message " + msg.getMsgId(), e); } finally { if (cmd != null) { @@ -1448,6 +1560,15 @@ private void sweepOrphanDmCollections() { } } + @Override + public synchronized void start() { + // Announce + implementation-mismatch check BEFORE the messaging thread spins up, so + // ImplementationCheck.THROW can abort startup with a plain exception to the caller (#280). + participantAnnouncer = new ParticipantAnnouncer(morphium, this, settings, NAME); + participantAnnouncer.announceAndCheck(); + super.start(); + } + public void run() { setName("Msg " + id); // Startup phase timings: readiness stalls under parallel load (waitForReady timeouts, @@ -1456,7 +1577,9 @@ public void run() { final long t0 = System.currentTimeMillis(); if (statusInfoListenerEnabled) { - listenerByName.put(statusInfoListenerName, Arrays.asList(statusInfoListener)); + Map> c = new HashMap<>(listenerByName); + c.put(statusInfoListenerName, Arrays.asList(statusInfoListener)); + listenerByName = c; } // Register with PoppyDB for optimizations if connected @@ -1535,6 +1658,8 @@ public void run() { try { // Liveness-check first — see checkMainThreadAlive() for the failure mode. checkMainThreadAlive(); + // Keep the server-side topic filter in sync with the registered listeners. + rebuildMainCsIfFilterStale(); // Cleanup old message tracking entries to prevent unbounded memory growth long cleanupTime = System.currentTimeMillis(); locallyProcessedMessageIds.entrySet().removeIf(entry -> @@ -1821,7 +1946,7 @@ public void run() { log.debug("Messaging " + id + " stopped!"); } - listenerByName.clear(); + listenerByName = new HashMap<>(); } @Override @@ -2424,6 +2549,13 @@ private boolean updateProcessedBy(Msg msg) { try { if (morphium.reread(msg, getCollectionName()) != null) { if (!msg.getProcessedBy().contains(id)) { + // Legacy/foreign document with an explicit processed_by: null - the + // $addToSet was rejected by mongod as a write error (#291). + if (LegacyProcessedByRepair.repairNullField(morphium, getCollectionName(), + queryId, processedByFieldName, id)) { + msg.getProcessedBy().add(id); + return true; + } log.warn(id + ": Could not update processed_by in msg " + msg.getMsgId()); log.warn(id + ": " + Utils.toJsonString(ret)); log.warn(id + ": msg: " + msg.toString()); @@ -2444,6 +2576,13 @@ private boolean updateProcessedBy(Msg msg) { return true; } } catch (MorphiumDriverException e) { + // The InMemoryDriver surfaces the $addToSet-on-null rejection as an exception + // rather than a write error - same legacy-document case, same repair (#291). + if (LegacyProcessedByRepair.repairNullField(morphium, getCollectionName(), + queryId, processedByFieldName, id)) { + msg.getProcessedBy().add(id); + return true; + } log.error("Error updating processed by - this might lead to duplicate execution!", e); return false; } finally { @@ -2480,10 +2619,19 @@ private void persistProcessedByMark(Msg msg) { cmd.setColl(getCollectionName()).setDb(morphium.getDatabase()); cmd.addUpdate(idq.toQueryObject(), Doc.of("$addToSet", Doc.of(processedByFieldName, id)), null, false, false, null, null, null); - cmd.execute(); + Map ret = cmd.execute(); cmd.releaseConnection(); cmd = null; + + // nModified=0 with a write error present means the legacy null-field shape (#291), + // not the benign already-marked/already-deleted cases this method tolerates. + if (ret.get("writeErrors") != null) { + LegacyProcessedByRepair.repairNullField(morphium, getCollectionName(), queryId, processedByFieldName, id); + } } catch (MorphiumDriverException e) { + if (LegacyProcessedByRepair.repairNullField(morphium, getCollectionName(), queryId, processedByFieldName, id)) { + return; + } log.error("Error persisting processed_by mark for answer " + msg.getMsgId(), e); } finally { if (cmd != null) { @@ -2673,7 +2821,10 @@ public void terminate() { networkRegistry.terminate(); } running = false; - listenerByName.clear(); + if (participantAnnouncer != null) { + participantAnnouncer.shutdown(); + } + listenerByName = new HashMap<>(); waitingForAnswers.clear(); processing.clear(); requestPoll.set(0); diff --git a/morphium-core/src/main/java/de/caluga/morphium/messaging/LegacyProcessedByRepair.java b/morphium-core/src/main/java/de/caluga/morphium/messaging/LegacyProcessedByRepair.java new file mode 100644 index 000000000..3b65c0a5b --- /dev/null +++ b/morphium-core/src/main/java/de/caluga/morphium/messaging/LegacyProcessedByRepair.java @@ -0,0 +1,79 @@ +package de.caluga.morphium.messaging; + +import java.util.List; +import java.util.Map; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.driver.Doc; +import de.caluga.morphium.driver.MorphiumDriverException; +import de.caluga.morphium.driver.commands.UpdateMongoCommand; + +/** + * Repair for legacy/foreign message documents whose {@code processed_by} is an explicit + * {@code null} (#291). Morphium senders initialize the field via Msg's {@code @PreStore}, but + * writers outside that lifecycle (other applications mapping the same collection without the + * guard, raw driver writers, restored dumps) store explicit nulls - and mongod rejects + * {@code $addToSet} on such a field ("Cannot apply $addToSet to non-array field ... has + * non-array type null"). Since exclusive messages must be marked BEFORE the listener runs, a + * failing mark means hard non-delivery, so every marking site falls back to this repair when its + * {@code $addToSet} did not take effect. + * + *

The repair is race-safe: the update is guarded by {@code {field: null}}, so it matches only + * the broken legacy shape - never an existing array, whose marks must not be clobbered. Running + * it only AFTER a failed {@code $addToSet} also rules out the pathological array-containing-null + * match: the field was non-array the moment the mark failed. If a concurrent instance repaired + * first, this update matches nothing and the caller's retried/rechecked {@code $addToSet} path + * takes over ($addToSet on the now-existing array is idempotent). + */ +final class LegacyProcessedByRepair { + + private static final Logger log = LoggerFactory.getLogger(LegacyProcessedByRepair.class); + + private LegacyProcessedByRepair() { + } + + /** + * Attempts {@code {_id: queryId, fieldName: null} -> {$set: {fieldName: [instanceId]}}} on + * {@code collection}. Returns {@code true} iff THIS call repaired the document - the instance + * id is then already contained in the fresh array, no further {@code $addToSet} needed. + */ + static boolean repairNullField(Morphium morphium, String collection, Object queryId, + String fieldName, String instanceId) { + if (morphium == null || morphium.getDriver() == null || morphium.getConfig() == null) { + return false; + } + + UpdateMongoCommand cmd = null; + + try { + cmd = new UpdateMongoCommand( + morphium.getDriver().getPrimaryConnection(morphium.getWriteConcernForClass(Msg.class))); + cmd.setColl(collection).setDb(morphium.getDatabase()); + Map query = Doc.of("_id", queryId); + query.put(fieldName, null); + cmd.addUpdate(query, Doc.of("$set", Doc.of(fieldName, List.of(instanceId))), + null, false, false, null, null, null); + Map ret = cmd.execute(); + cmd.releaseConnection(); + cmd = null; + Object modified = ret.get("nModified") != null ? ret.get("nModified") : ret.get("modified"); + boolean repaired = modified instanceof Number && ((Number) modified).intValue() > 0; + + if (repaired) { + log.info("{}: repaired legacy null {} on message {} in {} (#291)", instanceId, fieldName, queryId, collection); + } + + return repaired; + } catch (MorphiumDriverException e) { + log.warn("{}: could not repair legacy null {} on message {}", instanceId, fieldName, queryId, e); + return false; + } finally { + if (cmd != null) { + cmd.releaseConnection(); + } + } + } +} diff --git a/morphium-core/src/main/java/de/caluga/morphium/messaging/MessageRejectedException.java b/morphium-core/src/main/java/de/caluga/morphium/messaging/MessageRejectedException.java index 58acb3d28..a45633357 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/messaging/MessageRejectedException.java +++ b/morphium-core/src/main/java/de/caluga/morphium/messaging/MessageRejectedException.java @@ -46,14 +46,27 @@ public MessageRejectedException(String reason, boolean continueProcessing, boole cmd.setColl(msg.getCollectionName()).setDb(msg.getMorphium().getDatabase()); String processedByFieldName = msg.getMorphium().getARHelper().getMongoFieldName(Msg.class, Msg.Fields.processedBy.name()); cmd.addUpdate(Doc.of("_id", m.getMsgId()), Doc.of("$addToSet", Doc.of(processedByFieldName, msg.getSenderId())), null, false, false, null, null, null); - cmd.execute(); + var ret = cmd.execute(); + + // legacy/foreign document with an explicit processed_by: null (#291) + if (ret.get("writeErrors") != null) { + LegacyProcessedByRepair.repairNullField(msg.getMorphium(), msg.getCollectionName(), + m.getMsgId(), processedByFieldName, msg.getSenderId()); + } //not exclusive message is marked as processed by me } else { //releasing lock when exclusive - should not be checked until processing is removed var ret = msg.getMorphium().createQueryFor(MsgLock.class, msg.getLockCollectionName(m)).f("_id").eq(m.getMsgId()).delete(); } } catch (MorphiumDriverException e) { - LoggerFactory.getLogger(msg.getClass()).error("Error unlocking message", e); + // the InMemoryDriver surfaces the $addToSet-on-null rejection as an exception (#291) + if (!m.isExclusive() && LegacyProcessedByRepair.repairNullField(msg.getMorphium(), msg.getCollectionName(), + m.getMsgId(), msg.getMorphium().getARHelper().getMongoFieldName(Msg.class, Msg.Fields.processedBy.name()), + msg.getSenderId())) { + LoggerFactory.getLogger(msg.getClass()).debug(msg.getSenderId() + ": repaired legacy processed_by on rejected message"); + } else { + LoggerFactory.getLogger(msg.getClass()).error("Error unlocking message", e); + } } finally { if (cmd != null) { cmd.releaseConnection(); diff --git a/morphium-core/src/main/java/de/caluga/morphium/messaging/MessagingParticipant.java b/morphium-core/src/main/java/de/caluga/morphium/messaging/MessagingParticipant.java new file mode 100644 index 000000000..d65f004a6 --- /dev/null +++ b/morphium-core/src/main/java/de/caluga/morphium/messaging/MessagingParticipant.java @@ -0,0 +1,82 @@ +package de.caluga.morphium.messaging; + +import de.caluga.morphium.annotations.DefaultReadPreference; +import de.caluga.morphium.annotations.Entity; +import de.caluga.morphium.annotations.Id; +import de.caluga.morphium.annotations.ReadPreferenceLevel; +import de.caluga.morphium.annotations.SafetyLevel; +import de.caluga.morphium.annotations.WriteSafety; +import de.caluga.morphium.annotations.caching.NoCache; + +/** + * One heartbeat document per live messaging instance in the layout-independent + * {@code _participants} collection (#280). Every implementation writes the same shape + * here regardless of how it lays out its message collections, so an implementation mismatch on + * one queue can be detected even between participants that share no message collection at all - + * which is exactly the case that fails silently over the messaging channel itself. + * + *

Written and read by {@code ParticipantAnnouncer}; the {@code _id} is the instance's + * messaging sender id, so re-announcing (heartbeat) is a plain store/replace. + */ +@Entity(typeId = "msg_participant") +@NoCache +// Primary reads on purpose, same reasoning as Sequence: the mismatch check must see another +// instance's acknowledged announcement immediately - a secondary read under replication lag +// makes the THROW check silently miss a participant that announced moments ago (seen as a +// broken test on the loaded RS test phase). +@WriteSafety(timeout = 10000, level = SafetyLevel.BASIC) +@DefaultReadPreference(ReadPreferenceLevel.PRIMARY) +public class MessagingParticipant { + @Id + private String id; + private String implementation; + private String hostname; + private long startedAt; + private long lastSeen; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getImplementation() { + return implementation; + } + + public void setImplementation(String implementation) { + this.implementation = implementation; + } + + public String getHostname() { + return hostname; + } + + public void setHostname(String hostname) { + this.hostname = hostname; + } + + public long getStartedAt() { + return startedAt; + } + + public void setStartedAt(long startedAt) { + this.startedAt = startedAt; + } + + public long getLastSeen() { + return lastSeen; + } + + public void setLastSeen(long lastSeen) { + this.lastSeen = lastSeen; + } + + @Override + public String toString() { + return "MessagingParticipant{id=" + id + ", implementation=" + implementation + + ", hostname=" + hostname + ", lastSeen=" + lastSeen + "}"; + } +} diff --git a/morphium-core/src/main/java/de/caluga/morphium/messaging/MultiCollectionMessaging.java b/morphium-core/src/main/java/de/caluga/morphium/messaging/MultiCollectionMessaging.java index cd3a65a31..3debd3ecb 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/messaging/MultiCollectionMessaging.java +++ b/morphium-core/src/main/java/de/caluga/morphium/messaging/MultiCollectionMessaging.java @@ -62,6 +62,7 @@ public class MultiCollectionMessaging implements MorphiumMessaging { public final static String NAME = "MultiCollectionMessaging"; private Logger log = LoggerFactory.getLogger(MultiCollectionMessaging.class); private Morphium morphium; + private ParticipantAnnouncer participantAnnouncer; private MessagingSettings effectiveSettings; private ThreadPoolExecutor threadPool; private Set processingMessages = ConcurrentHashMap.newKeySet(); @@ -178,6 +179,10 @@ public String getDMCollectionName(String sender) { @SuppressWarnings("unchecked") @Override public void start() { + // Announce + implementation-mismatch check BEFORE anything spins up, so + // ImplementationCheck.THROW can abort startup with a plain exception to the caller (#280). + participantAnnouncer = new ParticipantAnnouncer(morphium, this, effectiveSettings, NAME); + participantAnnouncer.announceAndCheck(); running.set(true); decouplePool.scheduleWithFixedDelay(() -> { // Process poll triggers - handle DMs and regular topics. @@ -1192,10 +1197,18 @@ private void persistProcessedByMark(Msg msg) { null, false, false, null, null, null); if (!running.get()) return; // this happens during tests mainly - cmd.execute(); + Map ret = cmd.execute(); cmd.releaseConnection(); cmd = null; + + // legacy null-field shape surfaces as a write error on mongod (#291) + if (ret.get("writeErrors") != null) { + LegacyProcessedByRepair.repairNullField(morphium, collName, queryId, processedByFieldName, id); + } } catch (MorphiumDriverException e) { + if (LegacyProcessedByRepair.repairNullField(morphium, collName, queryId, processedByFieldName, id)) { + return; + } log.error("Error persisting processed_by mark for answer " + msg.getMsgId(), e); } finally { if (cmd != null) { @@ -1250,6 +1263,12 @@ private void updateProcessedBy(Msg msg) { return; } if (!msg.getProcessedBy().contains(id)) { + // Legacy/foreign document with an explicit processed_by: null - the + // $addToSet was rejected by mongod as a write error (#291). + if (LegacyProcessedByRepair.repairNullField(morphium, collName, queryId, processedByFieldName, id)) { + msg.getProcessedBy().add(id); + return; + } log.warn("{}: Could not update processed_by in msg {}", id, msg.getMsgId()); } return; @@ -1257,6 +1276,12 @@ private void updateProcessedBy(Msg msg) { msg.getProcessedBy().add(id); } catch (MorphiumDriverException e) { + // The InMemoryDriver surfaces the $addToSet-on-null rejection as an exception + // rather than a write error - same legacy-document case, same repair (#291). + if (LegacyProcessedByRepair.repairNullField(morphium, collName, queryId, processedByFieldName, id)) { + msg.getProcessedBy().add(id); + return; + } log.error("Error updating processed by - this might lead to duplicate execution!", e); } finally { if (cmd != null) { @@ -1738,6 +1763,9 @@ public void close() { @Override public void terminate() { running.set(false); + if (participantAnnouncer != null) { + participantAnnouncer.shutdown(); + } // Unregister from PoppyDB before terminating unregisterFromPoppyDB(); if (networkRegistry != null) { diff --git a/morphium-core/src/main/java/de/caluga/morphium/messaging/ParticipantAnnouncer.java b/morphium-core/src/main/java/de/caluga/morphium/messaging/ParticipantAnnouncer.java new file mode 100644 index 000000000..dda0c46a7 --- /dev/null +++ b/morphium-core/src/main/java/de/caluga/morphium/messaging/ParticipantAnnouncer.java @@ -0,0 +1,191 @@ +package de.caluga.morphium.messaging; + +import java.net.InetAddress; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.config.MessagingSettings; + +/** + * Announces a messaging instance in the layout-independent {@code _participants} + * collection and checks what implementation the other participants on the queue run (#280). + * + *

The check cannot run over the messaging channel itself: between two implementations with + * disjoint collection layouts (e.g. Standard vs. MultiCollection) no status message ever crosses + * over, so a registry-based check would be blind in exactly the broken case. The participants + * collection is derived from the queue NAME alone and therefore shared by every implementation. + * + *

Detection and diagnostics only - no bridging, no adoption of the other side's layout. + * Behaviour per {@link MessagingSettings.ImplementationCheck}: WARN (default) logs, THROW + * refuses startup, IGNORE skips announcement and check entirely. + */ +class ParticipantAnnouncer { + private static final Logger log = LoggerFactory.getLogger(ParticipantAnnouncer.class); + + private final Morphium morphium; + private final MorphiumMessaging owner; + private final MessagingSettings settings; + private final String implementationName; + private final String collectionName; + private final long startedAt = System.currentTimeMillis(); + /** mismatched participant ids already warned about - each offender is logged once */ + private final Set warnedAbout = ConcurrentHashMap.newKeySet(); + private ScheduledExecutorService heartbeat; + + ParticipantAnnouncer(Morphium morphium, MorphiumMessaging owner, MessagingSettings settings, + String implementationName) { + this.morphium = morphium; + this.owner = owner; + this.settings = settings; + this.implementationName = implementationName; + this.collectionName = participantsCollectionName(owner.getQueueName()); + } + + /** + * Same base-name derivation as the Standard/DualChannel message collection ("msg" / + * "mmsg_<queue>") so the name is a function of the QUEUE, not of any implementation's + * layout - MultiCollectionMessaging keys its message collections differently but must land + * in the same participants collection. + */ + static String participantsCollectionName(String queueName) { + // "msg" is MessagingSettings' default queue name; Standard/DualChannel report the + // default queue as null while MultiCollection reports the literal default - all three + // MUST land in the same collection or the check is blind exactly across implementations. + String base = (queueName == null || queueName.isEmpty() || queueName.equals("msg")) + ? "msg" : "mmsg_" + queueName; + return base + "_participants"; + } + + /** + * Announce this instance and check the other participants. Called synchronously from + * {@code start()} BEFORE the messaging threads spin up, so ImplementationCheck.THROW can + * abort startup cleanly (the own announcement is withdrawn again in that case). + * + * @throws IllegalStateException on a mismatch with ImplementationCheck.THROW + */ + void announceAndCheck() { + if (settings.getMessagingImplementationCheck() == MessagingSettings.ImplementationCheck.IGNORE) { + return; + } + + announce(); + List foreign = freshForeignParticipants(); + + if (!foreign.isEmpty()) { + String msg = "Messaging implementation mismatch on queue '" + owner.getCollectionName() + + "': this instance runs " + implementationName + ", but other participants run " + + describe(foreign) + ". The collection layouts are not interoperable - answers and " + + "directed messages between mismatched participants are lost silently (#280)."; + + if (settings.getMessagingImplementationCheck() == MessagingSettings.ImplementationCheck.THROW) { + withdraw(); + throw new IllegalStateException(msg); + } + + log.warn(msg); + foreign.forEach(p -> warnedAbout.add(p.getId())); + } + + long interval = Math.max(1, settings.getMessagingRegistryUpdateInterval()); + heartbeat = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "msg-participant-" + owner.getSenderId()); + t.setDaemon(true); + return t; + }); + heartbeat.scheduleWithFixedDelay(this::heartbeatTick, interval, interval, TimeUnit.SECONDS); + } + + /** Stop the heartbeat and withdraw this instance's announcement (called from terminate()). */ + void shutdown() { + if (heartbeat != null) { + heartbeat.shutdownNow(); + heartbeat = null; + } + withdraw(); + } + + private void heartbeatTick() { + try { + announce(); + cleanupStale(); + // Late joiners with a mismatched implementation can only be WARNed about - throwing + // on a background thread would reach nobody. Each offender is logged once. + for (MessagingParticipant p : freshForeignParticipants()) { + if (warnedAbout.add(p.getId())) { + log.warn("Messaging implementation mismatch on queue '{}': participant {} runs {}, " + + "this instance runs {} - traffic between the two is lost silently (#280)", + owner.getCollectionName(), p.getId(), p.getImplementation(), implementationName); + } + } + } catch (Exception e) { + // heartbeat must never kill its scheduler - next tick retries + log.debug("participant heartbeat failed: {}", e.getMessage()); + } + } + + private void announce() { + MessagingParticipant p = new MessagingParticipant(); + p.setId(owner.getSenderId()); + p.setImplementation(implementationName); + p.setHostname(hostname()); + p.setStartedAt(startedAt); + p.setLastSeen(System.currentTimeMillis()); + morphium.store(p, collectionName); + } + + private void withdraw() { + try { + MessagingParticipant p = new MessagingParticipant(); + p.setId(owner.getSenderId()); + morphium.delete(p, collectionName); + } catch (Exception e) { + log.debug("could not withdraw participant announcement: {}", e.getMessage()); + } + } + + private List freshForeignParticipants() { + long cutoff = System.currentTimeMillis() - settings.getMessagingRegistryParticipantTimeout(); + return participants().stream() + .filter(p -> !owner.getSenderId().equals(p.getId())) + .filter(p -> p.getLastSeen() >= cutoff) + .filter(p -> !implementationName.equals(p.getImplementation())) + .collect(Collectors.toList()); + } + + /** Dead instances leave a document per restart behind - prune anything long past the timeout. */ + private void cleanupStale() { + long cutoff = System.currentTimeMillis() - 3 * settings.getMessagingRegistryParticipantTimeout(); + for (MessagingParticipant p : participants()) { + if (p.getLastSeen() < cutoff) { + morphium.delete(p, collectionName); + } + } + } + + private List participants() { + return morphium.createQueryFor(MessagingParticipant.class, collectionName).asList(); + } + + private static String describe(List participants) { + return participants.stream() + .map(p -> p.getId() + " (" + p.getImplementation() + " on " + p.getHostname() + ")") + .collect(Collectors.joining(", ")); + } + + private static String hostname() { + try { + return InetAddress.getLocalHost().getHostName(); + } catch (Exception e) { + return "unknown"; + } + } +} diff --git a/morphium-core/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java b/morphium-core/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java index 292f41ac2..f826db789 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java +++ b/morphium-core/src/main/java/de/caluga/morphium/messaging/SingleCollectionMessaging.java @@ -66,7 +66,11 @@ public class SingleCollectionMessaging extends Thread implements ShutdownListene private String hostname; private final Map pauseMessages = new ConcurrentHashMap<>(); - private Map> listenerByName = new HashMap<>(); + // Written only via clone-and-swap (never mutated in place) and declared volatile: the + // poll thread iterates the current map lock-free (rebuildMainCsIfFilterStale / + // buildMainCsPipeline), so in-place put/remove/clear would race that iteration. + private volatile Map> listenerByName = new HashMap<>(); + private ParticipantAnnouncer participantAnnouncer; private String queueName; private String lockCollectionName = null; private String collectionName = null; @@ -86,6 +90,9 @@ public class SingleCollectionMessaging extends Thread implements ShutdownListene private volatile long lastCsRestartMs = 0; private final AtomicLong csStallRestarts = new AtomicLong(0); private List> changeStreamPipeline; + // Topic snapshot the live main-CS pipeline was built with; compared against + // listenerByName.keySet() by the poll loop to detect a stale filter. + private volatile Set csFilterTopics = Set.of(); private int changeStreamMaxWait; // Throttles the main-thread-death log so we don't spam every poll cycle once detected. private volatile long lastMainThreadDeathLogMs = 0; @@ -364,9 +371,11 @@ public String getStatusInfoListenerName() { @Override public void setStatusInfoListenerName(String statusInfoListenerName) { - listenerByName.remove(this.statusInfoListenerName); + Map> c = new HashMap<>(listenerByName); + c.remove(this.statusInfoListenerName); this.statusInfoListenerName = statusInfoListenerName; - listenerByName.put(statusInfoListenerName, Arrays.asList(statusInfoListener)); + c.put(statusInfoListenerName, Arrays.asList(statusInfoListener)); + listenerByName = c; } @Override @@ -405,9 +414,13 @@ public void setStatusInfoListenerEnabled(boolean statusInfoListenerEnabled) { this.statusInfoListenerEnabled = statusInfoListenerEnabled; if (statusInfoListenerEnabled && !listenerByName.containsKey(statusInfoListenerName)) { - listenerByName.put(statusInfoListenerName, Arrays.asList(statusInfoListener)); + Map> c = new HashMap<>(listenerByName); + c.put(statusInfoListenerName, Arrays.asList(statusInfoListener)); + listenerByName = c; } else if (!statusInfoListenerEnabled) { - listenerByName.remove(statusInfoListenerName); + Map> c = new HashMap<>(listenerByName); + c.remove(statusInfoListenerName); + listenerByName = c; } } @@ -607,7 +620,7 @@ private boolean handleChangeStreamEvent(ChangeStreamEvent evt) { // First check if already in progress (most important for preventing duplicates) if (idsInProgress.contains(messageId)) { traceDecision(messageId, msg.get("in_answer_to"), "cs-event: already in idsInProgress, skipped"); - log.warn("CHANGESTREAM DUPLICATE CAUGHT: message {} already in idsInProgress", messageId); + log.debug("CHANGESTREAM DUPLICATE CAUGHT: message {} already in idsInProgress", messageId); return running; } @@ -629,6 +642,18 @@ private boolean handleChangeStreamEvent(ChangeStreamEvent evt) { el.setTimestamp(System.currentTimeMillis()); } + // Fast path: for non-exclusive insert events the fullDocument is an + // authoritative snapshot - nothing mutates a non-exclusive message between + // insert and processing that our skip checks depend on, so the processing + // runnable can deserialize it directly and skip the per-message PRIMARY + // re-fetch. Exclusive messages deliberately do NOT get the document: their + // processed_by re-check after claiming the lock needs a fresh read + // (correctness, not overhead). Requeue updates and poll pickups never come + // through here and keep the re-fetch as staleness protection. + if ("insert".equals(evt.getOperationType()) && (exclusive == null || !exclusive)) { + el.setFullDocument(msg); + } + // Check if not already queued for processing if (!processing.contains(el)) { processing.add(el); @@ -636,11 +661,13 @@ private boolean handleChangeStreamEvent(ChangeStreamEvent evt) { // This must happen HERE, not in the processing thread, to close the race condition window idsInProgress.add(messageId); - traceDecision(messageId, msg.get("in_answer_to"), "cs-event: queued for processing"); + traceDecision(messageId, msg.get("in_answer_to"), el.getFullDocument() != null + ? "cs-event: queued for processing (fullDocument attached)" + : "cs-event: queued for processing"); log.debug("CSE: {}: Queued message {} for processing, queue size={}", id, messageId, processing.size()); } else { traceDecision(messageId, msg.get("in_answer_to"), "cs-event: already in processing queue, skipped"); - log.warn("CHANGESTREAM DUPLICATE CAUGHT: Message {} already in processing queue", messageId); + log.debug("CHANGESTREAM DUPLICATE CAUGHT: Message {} already in processing queue", messageId); } } } else { @@ -728,10 +755,47 @@ private void restartMainCsIfStalled(long stallThresholdMs) { log.warn("Main change stream for '{}' silent for {}ms while polling found backlog — restarting (restart #{})", getCollectionName(), silenceMs, csStallRestarts.incrementAndGet()); + replaceMainCsMonitor(old); + } + + /** + * Rebuild the main change stream when the registered topic set no longer matches the + * filter the live stream was built with (listener added/removed after the stream + * started — the topic clause in the server-side $match would otherwise silently drop + * broadcasts for newly registered topics, degrading them to fallback-poll latency). + * Runs on every poll tick; a no-op unless the topic set actually changed. Bursts of + * registrations therefore coalesce into a single rebuild. Delivery during the gap is + * covered by the poll (addListenerForTopic bumps requestPoll). + * + * Only call from the polling thread, same discipline as restartMainCsIfStalled(). + */ + private void rebuildMainCsIfFilterStale() { + if (!running || !useChangeStream) return; + if (changeStreamMonitor == null) return; + Set registered = Set.copyOf(listenerByName.keySet()); + if (registered.equals(csFilterTopics)) return; + + log.info("Topic set changed for '{}' ({} -> {}) — rebuilding main change stream filter", + getCollectionName(), csFilterTopics, registered); + changeStreamPipeline = buildMainCsPipeline(registered); + // Commit the snapshot only once the fresh monitor is actually up: a failed replace + // must leave csFilterTopics stale so the next poll tick retries the rebuild. + if (replaceMainCsMonitor(changeStreamMonitor)) { + csFilterTopics = registered; + } + } + + /** + * Terminate the given monitor and start a fresh one for the current + * changeStreamPipeline, rewired identically to the original. + * + * @return true if the fresh monitor is up, false if it could not be created/started + */ + private boolean replaceMainCsMonitor(ChangeStreamMonitor old) { try { old.terminate(); } catch (Exception e) { - log.warn("Error terminating stalled change stream for '{}': {}", getCollectionName(), e.getMessage()); + log.warn("Error terminating change stream for '{}': {}", getCollectionName(), e.getMessage()); } try { @@ -745,11 +809,21 @@ private void restartMainCsIfStalled(long stallThresholdMs) { // Reset markers — give the fresh stream the full threshold before re-evaluating. lastCsEventMs = System.currentTimeMillis(); lastCsRestartMs = lastCsEventMs; + return true; } catch (Exception e) { log.error("Failed to restart change stream for '{}'", getCollectionName(), e); + return false; } } + /** + * @return snapshot of the topics the live main change stream filter was built with — + * diagnostic counterpart to getCsStallRestarts() + */ + public Set getCsFilterTopics() { + return csFilterTopics; + } + /** * @return number of times the main change stream watchdog has triggered a restart since startup */ @@ -792,7 +866,15 @@ private void checkMainThreadAlive() { getCollectionName()); } - private void initChangeStreams() { + /** + * Build the $match pipeline for the main change stream, filtered server-side to + * what THIS instance can actually process, for the given snapshot of registered + * topics. The CALLER commits that snapshot to csFilterTopics — and must only do so + * once a stream built from this pipeline is actually up, so a failed (re)build + * leaves the staleness check failing and gets retried (see + * rebuildMainCsIfFilterStale()). + */ + private List> buildMainCsPipeline(Set registered) { // pipeline for reducing incoming traffic List> pipeline = new ArrayList<>(); Map match = new LinkedHashMap<>(); @@ -814,17 +896,40 @@ private void initChangeStreams() { // fallback poll (~FALLBACK_POLL_INTERVAL × pause latency). // // This filter restricts inserts to messages that are actually for this instance: - // - sender != my id → don't echo my own inserts - // - recipients null/me → broadcast or addressed to me + // - sender != my id → don't echo my own inserts + // - recipients me → addressed to me (answers pass regardless of topic) + // - recipients null + topic listened → broadcasts only for topics with a registered + // listener; everything else would be dropped client-side after a wasted wakeup, + // decode and processing-executor slot ("no listener for topic", see queueOrRun path) // lock_released events are passed through unchanged (no fullDocument). // Use translated Mongo field names so the pipeline survives camelCase mapping changes. String senderField = "fullDocument." + morphium.getARHelper().getMongoFieldName(Msg.class, Msg.Fields.sender.name()); String recipientsField = "fullDocument." + morphium.getARHelper().getMongoFieldName(Msg.class, Msg.Fields.recipients.name()); + String topicField = "fullDocument." + morphium.getARHelper().getMongoFieldName(Msg.class, Msg.Fields.topic.name()); + String inAnswerToField = "fullDocument." + morphium.getARHelper().getMongoFieldName(Msg.class, Msg.Fields.inAnswerTo.name()); + // The status-info topic is always watched: registry discovery must keep working + // regardless of listener registration state (#283). NOT part of the staleness + // snapshot below - it never changes with the listener set. + Set watchedTopics = new HashSet<>(registered); + watchedTopics.add(statusInfoListenerName); + // V5-legacy senders store only "name" - "topic" does not exist on those documents, + // and postLoad() maps it far too late for a server-side filter. preStore() sets + // name = topic on every 6.x send, so this clause only ever rescues legacy documents. + String legacyTopicField = "fullDocument.name"; + Map broadcastRelevant = new LinkedHashMap<>(); + broadcastRelevant.put(recipientsField, null); + // Broadcast answers (inAnswerTo set, no recipients) target the requester's waiter + // whatever topic they carry - they bypass the topic clause (#283). + broadcastRelevant.put("$or", Arrays.asList( + UtilsMap.of(topicField, UtilsMap.of("$in", new ArrayList<>(watchedTopics))), + UtilsMap.of(legacyTopicField, UtilsMap.of("$in", new ArrayList<>(watchedTopics))), + UtilsMap.of(inAnswerToField, UtilsMap.of("$ne", null)) + )); Map insertRelevant = new LinkedHashMap<>(); insertRelevant.put("operationType", "insert"); insertRelevant.put(senderField, UtilsMap.of("$ne", id)); insertRelevant.put("$or", Arrays.asList( - UtilsMap.of(recipientsField, null), + broadcastRelevant, UtilsMap.of(recipientsField, id) )); // Requeue detection: clearing processedBy via a plain DB update makes a message @@ -842,19 +947,28 @@ private void initChangeStreams() { insertRelevant )); pipeline.add(UtilsMap.of("$match", relevanceMatch)); + return pipeline; + } + + private void initChangeStreams() { // Use longer maxWait for change streams to avoid constant network polling // Change streams are designed to block server-side; short timeouts waste CPU/network changeStreamMaxWait = Math.max(pause * 10, morphium.getConfig().connectionSettings().getMaxWaitTime()); + Set registered = Set.copyOf(listenerByName.keySet()); + List> pipeline = buildMainCsPipeline(registered); changeStreamPipeline = pipeline; ChangeStreamMonitor lockMonitor = new ChangeStreamMonitor(morphium, getLockCollectionName(), false, changeStreamMaxWait, List.of(Doc.of("$match", Doc.of("operationType", Doc.of("$eq", "delete"))))); lockChangeStreamMonitor = lockMonitor; lockMonitor.addListener(evt -> { - // some lock removed - if (morphium.createQueryFor(Msg.class, getCollectionName()).f("_id").eq(evt.getDocumentKey()).countAll() != 0) { - // log.info("Lock CSE"); - requestPoll.incrementAndGet(); - } + // Some lock removed - ask for a poll. Deliberately no query here (#286): this runs on + // the change-stream callback thread, so a countAll per lock-delete event blocks the + // stream itself, and it buys nothing. requestPoll is a counter the poll loop reads, + // zeroes and answers with ONE findMessages(), so M lock deletes in a burst coalesce + // into a single poll either way - the gate traded M synchronous queries on this + // thread against at most one query in the poll thread. findMessages() decides what is + // actually pending, which it queries for correctly regardless. + requestPoll.incrementAndGet(); return running; }); changeStreamMonitor = new ChangeStreamMonitor(morphium, getCollectionName(), false, changeStreamMaxWait, pipeline); @@ -862,6 +976,9 @@ private void initChangeStreams() { // On every watch (re-)establishment poll once: messages inserted while the stream // was down are invisible to the new stream unless a resume token was available. changeStreamMonitor.addWatchEstablishedListener(requestPoll::incrementAndGet); + // Monitor construction succeeded — a throw above propagates and aborts startup, so + // committing the filter snapshot here can never record a filter no stream was built for. + csFilterTopics = registered; // Same for lock releases: a lock deleted during a lock-monitor gap would otherwise // never trigger its re-poll for exclusive messages. lockMonitor.addWatchEstablishedListener(requestPoll::incrementAndGet); @@ -880,11 +997,22 @@ private void initChangeStreams() { } } + @Override + public synchronized void start() { + // Announce + implementation-mismatch check BEFORE the messaging thread spins up, so + // ImplementationCheck.THROW can abort startup with a plain exception to the caller (#280). + participantAnnouncer = new ParticipantAnnouncer(morphium, this, settings, NAME); + participantAnnouncer.announceAndCheck(); + super.start(); + } + public void run() { setName("Msg " + id); if (statusInfoListenerEnabled) { - listenerByName.put(statusInfoListenerName, Arrays.asList(statusInfoListener)); + Map> c = new HashMap<>(listenerByName); + c.put(statusInfoListenerName, Arrays.asList(statusInfoListener)); + listenerByName = c; } // Register with PoppyDB for optimizations if connected @@ -918,6 +1046,8 @@ public void run() { try { // Liveness-check first — see checkMainThreadAlive() for the failure mode. checkMainThreadAlive(); + // Keep the server-side topic filter in sync with the registered listeners. + rebuildMainCsIfFilterStale(); // Cleanup old message tracking entries to prevent unbounded memory growth long cleanupTime = System.currentTimeMillis(); locallyProcessedMessageIds.entrySet().removeIf(entry -> @@ -1013,17 +1143,55 @@ public void run() { return; } - // CRITICAL: Use PRIMARY read preference to avoid stale reads from replicas - // With NEAREST, replica lag could cause us to see old processedBy values - // which would cause message processing to be incorrectly skipped - var q = morphium.createQueryFor(Msg.class) - .setReadPreferenceLevel(ReadPreferenceLevel.PRIMARY).f("_id").eq(finalPrEl.getId()); - q.setCollectionName(getCollectionName()); - msg = q.get(); + // Fast path: non-exclusive insert events carry the fullDocument snapshot + // (attached in handleChangeStreamEvent) - deserialize it directly and save + // the per-message DB roundtrip. All skip checks below run against the + // deserialized message exactly as they would against a re-fetched one. + Map fullDoc = finalPrEl.getFullDocument(); + + if (fullDoc != null) { + try { + msg = morphium.getMapper().deserialize(Msg.class, fullDoc); + // The raw mapper does not run entity lifecycle callbacks - fire + // @PostLoad explicitly (like the query path does after unmarshalling), + // otherwise Msg.postLoad()'s V5->V6 name->topic migration is skipped + // and legacy messages without a "topic" field get dropped silently. + if (msg != null) { + morphium.firePostLoadEvent(msg); + } + } catch (Exception e) { + log.warn("Could not deserialize change stream fullDocument for {} - falling back to re-fetch", finalPrEl.getId(), e); + msg = null; + } + + // Defensive: the fast path is for non-exclusive messages only. If an + // exclusive message ever slips through (or deserialization produced no + // id), discard and take the re-fetch path - exclusive semantics must + // stay byte-identical to the pre-fast-path behavior. + if (msg != null && (msg.isExclusive() || msg.getMsgId() == null)) { + msg = null; + } + + if (msg != null) { + traceDecision(msg.getMsgId(), msg.getInAnswerTo(), "processing: using change stream fullDocument (fast path, no re-fetch)"); + } + } if (msg == null) { - traceDecision(finalPrEl.getId(), null, "processing: reread returned null - message gone from collection"); - return; + // CRITICAL: Use PRIMARY read preference to avoid stale reads from replicas + // With NEAREST, replica lag could cause us to see old processedBy values + // which would cause message processing to be incorrectly skipped + var q = morphium.createQueryFor(Msg.class) + .setReadPreferenceLevel(ReadPreferenceLevel.PRIMARY).f("_id").eq(finalPrEl.getId()); + q.setCollectionName(getCollectionName()); + msg = q.get(); + + if (msg == null) { + traceDecision(finalPrEl.getId(), null, "processing: reread returned null - message gone from collection"); + return; + } + + traceDecision(msg.getMsgId(), msg.getInAnswerTo(), "processing: re-fetched from database"); } // do not process if no listener registered for this message @@ -1169,7 +1337,7 @@ public void run() { log.debug("Messaging " + id + " stopped!"); } - listenerByName.clear(); + listenerByName = new HashMap<>(); } @Override @@ -1772,6 +1940,13 @@ private boolean updateProcessedBy(Msg msg) { try { if (morphium.reread(msg, getCollectionName()) != null) { if (!msg.getProcessedBy().contains(id)) { + // Legacy/foreign document with an explicit processed_by: null - the + // $addToSet was rejected by mongod as a write error (#291). + if (LegacyProcessedByRepair.repairNullField(morphium, getCollectionName(), + queryId, processedByFieldName, id)) { + msg.getProcessedBy().add(id); + return true; + } log.warn(id + ": Could not update processed_by in msg " + msg.getMsgId()); log.warn(id + ": " + Utils.toJsonString(ret)); log.warn(id + ": msg: " + msg.toString()); @@ -1792,6 +1967,13 @@ private boolean updateProcessedBy(Msg msg) { return true; } } catch (MorphiumDriverException e) { + // The InMemoryDriver surfaces the $addToSet-on-null rejection as an exception + // rather than a write error - same legacy-document case, same repair (#291). + if (LegacyProcessedByRepair.repairNullField(morphium, getCollectionName(), + queryId, processedByFieldName, id)) { + msg.getProcessedBy().add(id); + return true; + } log.error("Error updating processed by - this might lead to duplicate execution!", e); return false; } finally { @@ -1828,10 +2010,19 @@ private void persistProcessedByMark(Msg msg) { cmd.setColl(getCollectionName()).setDb(morphium.getDatabase()); cmd.addUpdate(idq.toQueryObject(), Doc.of("$addToSet", Doc.of(processedByFieldName, id)), null, false, false, null, null, null); - cmd.execute(); + Map ret = cmd.execute(); cmd.releaseConnection(); cmd = null; + + // nModified=0 with a write error present means the legacy null-field shape (#291), + // not the benign already-marked/already-deleted cases this method tolerates. + if (ret.get("writeErrors") != null) { + LegacyProcessedByRepair.repairNullField(morphium, getCollectionName(), queryId, processedByFieldName, id); + } } catch (MorphiumDriverException e) { + if (LegacyProcessedByRepair.repairNullField(morphium, getCollectionName(), queryId, processedByFieldName, id)) { + return; + } log.error("Error persisting processed_by mark for answer " + msg.getMsgId(), e); } finally { if (cmd != null) { @@ -2021,7 +2212,10 @@ public void terminate() { networkRegistry.terminate(); } running = false; - listenerByName.clear(); + if (participantAnnouncer != null) { + participantAnnouncer.shutdown(); + } + listenerByName = new HashMap<>(); waitingForAnswers.clear(); processing.clear(); requestPoll.set(0); @@ -2551,6 +2745,10 @@ public static class ProcessingQueueElement implements Comparable fullDocument; public ProcessingQueueElement() { } @@ -2588,6 +2786,15 @@ public ProcessingQueueElement setId(MorphiumId id) { return this; } + public Map getFullDocument() { + return fullDocument; + } + + public ProcessingQueueElement setFullDocument(Map fullDocument) { + this.fullDocument = fullDocument; + return this; + } + @Override public int compareTo(ProcessingQueueElement o) { if (o.getPriority() < priority) diff --git a/morphium-core/src/main/java/de/caluga/morphium/writer/BufferedMorphiumWriterImpl.java b/morphium-core/src/main/java/de/caluga/morphium/writer/BufferedMorphiumWriterImpl.java index 3f34d84a3..0b93dc362 100644 --- a/morphium-core/src/main/java/de/caluga/morphium/writer/BufferedMorphiumWriterImpl.java +++ b/morphium-core/src/main/java/de/caluga/morphium/writer/BufferedMorphiumWriterImpl.java @@ -906,7 +906,7 @@ public Map remove(final Query q, boolean multiple, AsyncO morphium.firePreRemoveEvent(q); DeleteBulkRequest r = ctx.addDeleteBulkRequest(); r.setQuery(Doc.of(q.toQueryObject())); - // ctx.addRequest(r); + r.setMultiple(multiple); morphium.firePostRemoveEvent(q); }, c, AsyncOperationType.REMOVE); return null; diff --git a/morphium-core/src/test/java/de/caluga/morphium/driver/inmem/ChangeStreamEventOrderingTest.java b/morphium-core/src/test/java/de/caluga/morphium/driver/inmem/ChangeStreamEventOrderingTest.java new file mode 100644 index 000000000..af18e6b4d --- /dev/null +++ b/morphium-core/src/test/java/de/caluga/morphium/driver/inmem/ChangeStreamEventOrderingTest.java @@ -0,0 +1,102 @@ +package de.caluga.morphium.driver.inmem; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import de.caluga.morphium.driver.Doc; +import de.caluga.morphium.driver.DriverTailableIterationCallback; +import de.caluga.morphium.driver.commands.WatchCommand; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Change-stream events must reach a subscriber in the order the writes happened - mongod + * guarantees per-cursor ordering, so the in-memory driver has to as well. + * + *

Regression for the client-mode dispatcher: each event used to be submitted as its own task + * to a cached thread pool, which does not preserve submission order - under CPU contention two + * back-to-back events could be delivered swapped (first seen as a spurious + * ReplaceChangeStreamEventTest failure on the loaded test runner: the $set "update" and the + * subsequent "replace" arrived inverted). + */ +@Tag("core") +public class ChangeStreamEventOrderingTest { + + private static final String DB = "cs_order_db"; + private static final String COLL = "probe"; + /** insert + WRITES updates */ + private static final int WRITES = 500; + private static final int EXPECTED_EVENTS = WRITES + 1; + + @Test + public void eventsArriveInWriteOrder() throws Exception { + InMemoryDriver drv = new InMemoryDriver(); + drv.connect(); + List> events = new CopyOnWriteArrayList<>(); + + try { + var con = drv.getPrimaryConnection(null); + WatchCommand w = new WatchCommand(con).setDb(DB).setColl(COLL) + .setCb(new DriverTailableIterationCallback() { + @Override + public void incomingData(Map data, long dur) { + events.add(data); + } + @Override + public boolean isContinued() { + // terminate the watch loop once everything arrived - see + // ReplaceChangeStreamEventTest for why leaking the subscription is not ok + return events.size() < EXPECTED_EVENTS; + } + }); + Thread watcher = new Thread(() -> { + try { + drv.watch(w); + } catch (Exception ignored) { + } + }); + watcher.setDaemon(true); + watcher.start(); + Thread.sleep(300); + + drv.store(DB, COLL, new ArrayList<>(List.of(Doc.of("_id", 1, "seq", 0))), null); + for (int i = 1; i <= WRITES; i++) { + drv.update(DB, COLL, Doc.of("_id", 1), null, Doc.of("$set", Doc.of("seq", i)), false, false, null, null); + } + + long deadline = System.currentTimeMillis() + 15000; + while (events.size() < EXPECTED_EVENTS && System.currentTimeMillis() < deadline) { + Thread.sleep(50); + } + watcher.join(5000); + + assertThat(events).as("every write must be delivered").hasSize(EXPECTED_EVENTS); + assertThat(events.get(0).get("operationType")).isEqualTo("insert"); + + List received = new ArrayList<>(); + for (int i = 1; i < events.size(); i++) { + Map evt = events.get(i); + assertThat(evt.get("operationType")).as("event %d", i).isEqualTo("update"); + @SuppressWarnings("unchecked") + Map updated = + (Map) ((Map) evt.get("updateDescription")).get("updatedFields"); + received.add(((Number) updated.get("seq")).intValue()); + } + + List expected = new ArrayList<>(); + for (int i = 1; i <= WRITES; i++) { + expected.add(i); + } + assertThat(received) + .as("update events must arrive in write order (mongod guarantees per-cursor ordering)") + .containsExactlyElementsOf(expected); + } finally { + drv.close(); + } + } +} diff --git a/morphium-core/src/test/java/de/caluga/morphium/driver/inmem/IndexStoreStalePublishRaceTest.java b/morphium-core/src/test/java/de/caluga/morphium/driver/inmem/IndexStoreStalePublishRaceTest.java new file mode 100644 index 000000000..d1f2a8887 --- /dev/null +++ b/morphium-core/src/test/java/de/caluga/morphium/driver/inmem/IndexStoreStalePublishRaceTest.java @@ -0,0 +1,145 @@ +package de.caluga.morphium.driver.inmem; + +import de.caluga.morphium.IndexDescription; +import de.caluga.morphium.driver.Doc; +import de.caluga.morphium.driver.MorphiumDriverException; +import de.caluga.morphium.driver.commands.CreateIndexesCommand; +import de.caluga.morphium.driver.commands.auth.CreateUserAdminCommand; +import de.caluga.morphium.driver.inmem.auth.UserDocuments; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Issue #290: {@code getIndexStore} may be entered without the collection lock (explain / + * slow-query paths), which opens a publish race with every store-invalidating mutation - a store + * built from the pre-mutation document list can be published AFTER the mutation's invalidate, and + * then serves stale data to all later readers until the next invalidate. The same shape exists + * for whole-DB drops, which remove the affected stores without going through + * {@code invalidateIndexStore}. + * + *

The race window (between the documents snapshot in {@code buildIndexStore} and the publish + * in {@code getIndexStore}) is made deterministic here by overriding the package-private + * {@code buildIndexStore} to run the concurrent mutation after the snapshot is taken but before + * the caller publishes it. Lives in the driver's own package to reach + * {@code getIndexStore}/{@code buildIndexStore}. + */ +@Tag("inmemory") +public class IndexStoreStalePublishRaceTest { + + private static final String USERS_DB = "admin"; + private static final String USERS_COLLECTION = "system.users"; + private static final String USER_NAME = "bob"; + + private RacingDriver drv; + + /** + * Lets the test inject a mutation into the window between {@code buildIndexStore}'s document + * snapshot and the publish in {@code getIndexStore} - exactly where a concurrent thread's + * write+invalidate lands in the real race. Fires only for the given namespace, and only once + * (the hook's own write paths trigger builds too and must not recurse). + */ + private static class RacingDriver extends InMemoryDriver { + private final String targetDb; + private final String targetColl; + volatile Runnable betweenSnapshotAndPublish; + + RacingDriver(String targetDb, String targetColl) { + this.targetDb = targetDb; + this.targetColl = targetColl; + } + + @Override + CollectionIndexStore buildIndexStore(String db, String collection) throws MorphiumDriverException { + CollectionIndexStore store = super.buildIndexStore(db, collection); + if (targetDb.equals(db) && targetColl.equals(collection)) { + Runnable hook = betweenSnapshotAndPublish; + if (hook != null) { + betweenSnapshotAndPublish = null; + hook.run(); + } + } + return store; + } + } + + @AfterEach + void tearDown() { + if (drv != null) { + drv.close(); + } + } + + /** Driver whose next admin.system.users store build races the issue's createUser writer. */ + private RacingDriver driverRacingCreateUser() throws Exception { + RacingDriver racing = new RacingDriver(USERS_DB, USERS_COLLECTION); + racing.connect(); + // The concurrent writer from the issue: createUser adds to admin.system.users directly + // and calls invalidateIndexStore - racing the build our test thread has in flight. + racing.betweenSnapshotAndPublish = () -> { + CreateUserAdminCommand cmd = new CreateUserAdminCommand(null).setUserName(USER_NAME).setPwd("pw"); + cmd.setDb(USERS_DB); + Map result = racing.readSingleAnswer(racing.runCommand(cmd)); + if (!Double.valueOf(1.0).equals(result.get("ok"))) { + throw new IllegalStateException("createUser failed: " + result); + } + }; + return racing; + } + + @Test + void storePublishedPastConcurrentInvalidateMustNotServeStaleData() throws Exception { + drv = driverRacingCreateUser(); + + // Thread A from the issue: enters getIndexStore lock-free, snapshots the (still empty) + // collection, and publishes - while the hook's createUser lands in between. + drv.getIndexStore(USERS_DB, USERS_COLLECTION); + + CollectionIndexStore published = drv.getIndexStore(USERS_DB, USERS_COLLECTION); + assertTrue(published.containsId(UserDocuments.userId(USERS_DB, USER_NAME)), + "the index store visible after the concurrent invalidate must contain the concurrently created user"); + } + + @Test + void duplicateIdCheckMustSeeUserWrittenConcurrentlyWithStoreBuild() throws Exception { + drv = driverRacingCreateUser(); + + drv.getIndexStore(USERS_DB, USERS_COLLECTION); + + // Worst case from the issue: the generic insert's duplicate-_id check runs against the + // stale store, misses the concurrently created user and admits a second document with + // the same _id. + String bobId = UserDocuments.userId(USERS_DB, USER_NAME); + assertThrows(MorphiumDriverException.class, + () -> drv.insert(USERS_DB, USERS_COLLECTION, List.of(Doc.of("_id", bobId)), null), + "inserting a document with the _id of the concurrently created user must be rejected as a duplicate"); + } + + @Test + void dropDatabaseDuringBuildMustNotResurrectDroppedDocuments() throws Exception { + String db = "racedb"; + String coll = "stuff"; + drv = new RacingDriver(db, coll); + drv.connect(); + drv.insert(db, coll, List.of(Doc.of("_id", "doc1")), null); + // Structural invalidate so the racing getIndexStore below has to build from scratch. + new CreateIndexesCommand(drv).setDb(db).setColl(coll) + .addIndex(new IndexDescription().setKey(Doc.of("counter", 1))) + .execute(); + + // The whole-DB drop removes the collection's store WITHOUT invalidateIndexStore - a + // build racing it must not re-publish the pre-drop snapshot afterwards. + drv.betweenSnapshotAndPublish = () -> drv.drop(db, null); + drv.getIndexStore(db, coll); + + assertFalse(drv.getIndexStore(db, coll).containsId("doc1"), + "the index store visible after a concurrent dropDatabase must not contain pre-drop documents"); + } +} diff --git a/morphium-core/src/test/java/de/caluga/morphium/driver/inmem/MultikeyIndexQueryTest.java b/morphium-core/src/test/java/de/caluga/morphium/driver/inmem/MultikeyIndexQueryTest.java new file mode 100644 index 000000000..ab7e0b57c --- /dev/null +++ b/morphium-core/src/test/java/de/caluga/morphium/driver/inmem/MultikeyIndexQueryTest.java @@ -0,0 +1,123 @@ +package de.caluga.morphium.driver.inmem; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import de.caluga.morphium.driver.Doc; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * An index on an array field must never change what a query returns (#289). + * + *

{@code CollectionIndexStore} does not implement multikey indexing - a terminal {@code List} + * becomes ONE key holding the whole list (see {@link IndexKey#extract}). An equality query builds + * a scalar lookup key, which can never match that, so once {@code find()}/{@code count()} started + * being served from an index the query silently returned nothing. Real-world shape: {@code Msg} + * carries {@code @Index} on {@code processedBy} plus compound indexes over {@code processed_by}, + * and a downstream planner selecting work with {@code processed_by == "Planner"} found none. + * + *

Until multikey indexing exists, such an index must not be used for lookups at all - the scan + * that {@code QueryHelper} performs evaluates MongoDB's array-membership semantics correctly. + */ +@Tag("core") +public class MultikeyIndexQueryTest { + + private static final String DB = "multikey_db"; + + private List> docs() { + List> docs = new ArrayList<>(); + docs.add(Doc.of("_id", 1, "processed_by", new ArrayList<>(List.of("Planner")), "priority", 545)); + docs.add(Doc.of("_id", 2, "processed_by", new ArrayList(), "priority", 100)); + docs.add(Doc.of("_id", 3, "processed_by", new ArrayList<>(List.of("other", "Planner")), "priority", 100)); + return docs; + } + + private List ids(List> res) { + List ids = new ArrayList<>(); + for (var d : res) { + ids.add(d.get("_id")); + } + return ids; + } + + @Test + public void indexedArrayFieldAnswersEqualityLikeAnUnindexedOne() throws Exception { + InMemoryDriver drv = new InMemoryDriver(); + drv.connect(); + + try { + drv.createIndex(DB, "indexed", Doc.of("processed_by", 1), Doc.of("name", "pb_1")); + drv.createIndex(DB, "indexed", Doc.of("processed_by", 1, "priority", 1), Doc.of("name", "pb_prio_1")); + drv.store(DB, "indexed", docs(), null); + drv.store(DB, "plain", docs(), null); + + // array membership: mongod matches a document whose array CONTAINS the value + assertThat(ids(drv.find(DB, "plain", Doc.of("processed_by", "Planner"), null, null, 0, 0))) + .as("baseline without an index") + .containsExactlyInAnyOrder(1, 3); + assertThat(ids(drv.find(DB, "indexed", Doc.of("processed_by", "Planner"), null, null, 0, 0))) + .as("an index on the array field must not change the result (#289)") + .containsExactlyInAnyOrder(1, 3); + + // compound index over the array field plus a scalar + assertThat(ids(drv.find(DB, "indexed", Doc.of("processed_by", "Planner", "priority", 545), null, null, 0, 0))) + .as("compound index whose leading field is an array") + .containsExactly(1); + + // these two always worked and must keep working - the messaging poll uses exactly them + assertThat(ids(drv.find(DB, "indexed", Doc.of("processed_by.0", Doc.of("$exists", false)), null, null, 0, 0))) + .as("empty-array probe") + .containsExactly(2); + assertThat(ids(drv.find(DB, "indexed", Doc.of("processed_by", Doc.of("$ne", "x")), null, null, 0, 0))) + .as("$ne on an array field") + .containsExactlyInAnyOrder(1, 2, 3); + + // counts must agree with finds + assertThat(drv.count(DB, "indexed", Doc.of("processed_by", "Planner"), null, null)) + .as("count must agree with find") + .isEqualTo(2); + + // a scalar field on the same collection still gets index-backed lookups + assertThat(ids(drv.find(DB, "indexed", Doc.of("priority", 545), null, null, 0, 0))) + .containsExactly(1); + } finally { + drv.close(); + } + } + + @Test + public void indexOverDottedPathWithArrayMidPathAnswersEqualityLikeAnUnindexedOne() throws Exception { + InMemoryDriver drv = new InMemoryDriver(); + drv.connect(); + + try { + List> docs = new ArrayList<>(); + docs.add(Doc.of("_id", 1, "a", new ArrayList<>(List.of(Doc.of("b", "x"), Doc.of("b", "y"))))); + docs.add(Doc.of("_id", 2, "a", new ArrayList<>(List.of(Doc.of("b", "z"))))); + docs.add(Doc.of("_id", 3, "a", Doc.of("b", "x"))); + + drv.createIndex(DB, "midpath", Doc.of("a.b", 1), Doc.of("name", "ab_1")); + drv.store(DB, "midpath", docs, null); + drv.store(DB, "midpath_plain", docs, null); + + // mongod traverses the mid-path array and matches per element + assertThat(ids(drv.find(DB, "midpath_plain", Doc.of("a.b", "x"), null, null, 0, 0))) + .as("baseline without an index") + .containsExactlyInAnyOrder(1, 3); + assertThat(ids(drv.find(DB, "midpath", Doc.of("a.b", "x"), null, null, 0, 0))) + .as("an index over a dotted path with an array mid-path must not change the result (#289)") + .containsExactlyInAnyOrder(1, 3); + + assertThat(drv.count(DB, "midpath", Doc.of("a.b", "x"), null, null)) + .as("count must agree with find") + .isEqualTo(2); + } finally { + drv.close(); + } + } +} diff --git a/morphium-core/src/test/java/de/caluga/morphium/driver/inmem/ReplaceChangeStreamEventTest.java b/morphium-core/src/test/java/de/caluga/morphium/driver/inmem/ReplaceChangeStreamEventTest.java new file mode 100644 index 000000000..9192128ff --- /dev/null +++ b/morphium-core/src/test/java/de/caluga/morphium/driver/inmem/ReplaceChangeStreamEventTest.java @@ -0,0 +1,170 @@ +package de.caluga.morphium.driver.inmem; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import de.caluga.morphium.driver.Doc; +import de.caluga.morphium.driver.DriverTailableIterationCallback; +import de.caluga.morphium.driver.commands.WatchCommand; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * A full-document replacement (an update whose update-document carries no $ operators, i.e. what + * a client's replaceOne sends) must reach change-stream watchers as operationType "replace" - + * measured against mongod: replace carries the new fullDocument and deliberately NO + * updateDescription, since a replacement has no meaningful per-field delta. + * + * Regression for #288: the replacement branch applied the change, updated the index store and + * re-queued the TTL entry, then `continue`d past the notification code - the document changed + * silently, invisible to every watcher (messaging, cache sync, PoppyDB replication). + */ +@Tag("core") +public class ReplaceChangeStreamEventTest { + + private static final String DB = "replace_evt_db"; + private static final String COLL = "probe"; + /** insert + operator update + replacement */ + private static final int EXPECTED_EVENTS = 3; + + @Test + public void replacementEmitsReplaceEventOperatorUpdateEmitsUpdate() throws Exception { + InMemoryDriver drv = new InMemoryDriver(); + drv.connect(); + List> events = new CopyOnWriteArrayList<>(); + + try { + var con = drv.getPrimaryConnection(null); + WatchCommand w = new WatchCommand(con).setDb(DB).setColl(COLL) + .setCb(new DriverTailableIterationCallback() { + @Override + public void incomingData(Map data, long dur) { + events.add(data); + } + @Override + public boolean isContinued() { + // Must go false once the expected events are in: a callback that answers + // "true" forever keeps the watch loop - and with it the driver's event + // dispatcher and this subscription - alive past the test ("Keeping + // eventDispatcher alive - 1 active subscription(s) remain" on close()). + // In a full-suite run that leak is what turns a tight heap into an OOM. + return events.size() < EXPECTED_EVENTS; + } + }); + Thread watcher = new Thread(() -> { + try { + drv.watch(w); + } catch (Exception ignored) { + } + }); + watcher.setDaemon(true); + watcher.start(); + Thread.sleep(300); + + drv.store(DB, COLL, new ArrayList<>(List.of(Doc.of("_id", 1, "a", 1))), null); + // update WITH operator - mongod: "update" plus updateDescription + drv.update(DB, COLL, Doc.of("_id", 1), null, Doc.of("$set", Doc.of("a", 2)), false, false, null, null); + // update WITHOUT operators == replaceOne - mongod: "replace", no updateDescription + drv.update(DB, COLL, Doc.of("_id", 1), null, Doc.of("b", 3), false, false, null, null); + + long deadline = System.currentTimeMillis() + 5000; + while (events.size() < EXPECTED_EVENTS && System.currentTimeMillis() < deadline) { + Thread.sleep(50); + } + // let the watch loop observe isContinued() == false and unwind before asserting, + // so the subscription is gone even if an assertion below fails + watcher.join(5000); + + assertThat(events).as("insert, operator update and replacement must all be delivered") + .hasSize(EXPECTED_EVENTS); + + assertThat(events.get(0).get("operationType")).isEqualTo("insert"); + + assertThat(events.get(1).get("operationType")).as("$set is an operator update").isEqualTo("update"); + assertThat(events.get(1)).as("an operator update reports its per-field delta") + .containsKey("updateDescription"); + + Map replaceEvt = events.get(2); + assertThat(replaceEvt.get("operationType")) + .as("an update without $ operators is a replacement, not an update (#288)") + .isEqualTo("replace"); + assertThat(replaceEvt).as("mongod sends no updateDescription for a replacement") + .doesNotContainKey("updateDescription"); + + // and the replacement really replaced rather than merged + var after = drv.find(DB, COLL, Doc.of("_id", 1), null, null, 0, 1); + assertThat(after).hasSize(1); + assertThat(after.get(0)).containsEntry("b", 3).doesNotContainKey("a"); + } finally { + drv.close(); + } + } + + /** + * The other half of #288: morphium.store() on an existing document goes out on the wire as + * an update WITH $set (see StoreMongoCommand), so mongod reports operationType "update" with + * an updateDescription. The in-memory store() path used to report "replace" for the same + * call - same API, different event type depending on the backend. + */ + @Test + public void storeOnExistingDocumentEmitsUpdateLikeMongod() throws Exception { + InMemoryDriver drv = new InMemoryDriver(); + drv.connect(); + List> events = new CopyOnWriteArrayList<>(); + + try { + var con = drv.getPrimaryConnection(null); + WatchCommand w = new WatchCommand(con).setDb(DB).setColl("store_probe") + .setCb(new DriverTailableIterationCallback() { + @Override + public void incomingData(Map data, long dur) { + events.add(data); + } + @Override + public boolean isContinued() { + return events.size() < 2; + } + }); + Thread watcher = new Thread(() -> { + try { + drv.watch(w); + } catch (Exception ignored) { + } + }); + watcher.setDaemon(true); + watcher.start(); + Thread.sleep(300); + + drv.store(DB, "store_probe", new ArrayList<>(List.of(Doc.of("_id", 1, "a", 1))), null); + // store of an EXISTING document - the ORM's store() sends {$set: doc} to mongod + drv.store(DB, "store_probe", new ArrayList<>(List.of(Doc.of("_id", 1, "a", 2))), null); + + long deadline = System.currentTimeMillis() + 5000; + while (events.size() < 2 && System.currentTimeMillis() < deadline) { + Thread.sleep(50); + } + watcher.join(5000); + + assertThat(events).as("insert and re-store must both be delivered").hasSize(2); + assertThat(events.get(0).get("operationType")).isEqualTo("insert"); + + Map updateEvt = events.get(1); + assertThat(updateEvt.get("operationType")) + .as("store() on an existing document is a $set update on the wire, not a replaceOne (#288)") + .isEqualTo("update"); + assertThat(updateEvt).as("mongod reports the per-field delta for that update") + .containsKey("updateDescription"); + @SuppressWarnings("unchecked") + Map updated = + (Map) ((Map) updateEvt.get("updateDescription")).get("updatedFields"); + assertThat(updated).containsEntry("a", 2); + } finally { + drv.close(); + } + } +} diff --git a/morphium-core/src/test/java/de/caluga/morphium/driver/inmem/TtlCappedTest.java b/morphium-core/src/test/java/de/caluga/morphium/driver/inmem/TtlCappedTest.java index 27925de68..a95585a62 100644 --- a/morphium-core/src/test/java/de/caluga/morphium/driver/inmem/TtlCappedTest.java +++ b/morphium-core/src/test/java/de/caluga/morphium/driver/inmem/TtlCappedTest.java @@ -35,6 +35,31 @@ private InMemoryDriver freshDriver() throws Exception { return drv; } + @Test + void ttlWithPartialFilterOnlyExpiresCoveredDocs() throws Exception { + // mongod's TTL monitor deletes only documents matching the index's + // partialFilterExpression - uncovered documents must survive their expiry time. + InMemoryDriver drv = freshDriver(); + drv.setExpireCheck(100); + drv.createIndex(db, coll, Doc.of("expiresAt", 1), + Doc.of("name", "ttl_partial", "expireAfterSeconds", 0, + "partialFilterExpression", Doc.of("status", "done"))); + + Date past = new Date(System.currentTimeMillis() - 5000); + new InsertMongoCommand(drv).setDb(db).setColl(coll) + .setDocuments(List.of( + Doc.of("counter", 1, "status", "done", "expiresAt", past), + Doc.of("counter", 2, "status", "open", "expiresAt", past))) + .execute(); + + TestUtils.waitForConditionToBecomeTrue(10_000, "covered TTL-expired document was never removed", + () -> drv.find(db, coll, Doc.of("counter", 1), null, null, 0, 0).isEmpty()); + // Both entries were due in the same sweep pass - the covered one is gone, so the + // uncovered one's queue entry has been processed too and must have been skipped. + assertEquals(1, drv.find(db, coll, Doc.of("counter", 2), null, null, 0, 0).size(), + "a document outside the partial filter must not be TTL-deleted"); + } + @Test void ttlDocExpiresWithinOneSweepAfterItsTime() throws Exception { InMemoryDriver drv = freshDriver(); diff --git a/morphium-core/src/test/java/de/caluga/morphium/driver/inmem/TtlQueueInvalidationTest.java b/morphium-core/src/test/java/de/caluga/morphium/driver/inmem/TtlQueueInvalidationTest.java new file mode 100644 index 000000000..9fe22bcf2 --- /dev/null +++ b/morphium-core/src/test/java/de/caluga/morphium/driver/inmem/TtlQueueInvalidationTest.java @@ -0,0 +1,131 @@ +package de.caluga.morphium.driver.inmem; + +import de.caluga.morphium.driver.Doc; +import de.caluga.morphium.driver.commands.InsertMongoCommand; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Regression test for #269: the TTL expiry queue must be rebuilt by whichever of the two lazy + * rebuild paths runs first after an {@code invalidateTtlQueue()}. + * + *

{@code invalidateTtlQueue()} removes a collection's queue outright, and both + * {@code sweepTtlQueue()} and {@code ttlEnqueue()} are supposed to rebuild it on miss. Before the + * fix only the sweep did; {@code ttlEnqueue()} used {@code computeIfAbsent} and so put a fresh, + * otherwise-EMPTY queue in place holding nothing but the document it was called for. That queue is + * no longer absent, so the sweep's bootstrap-on-miss never fires again and every document that + * existed before the invalidation permanently loses its expiry tracking. + * + *

Concretely for messaging: {@code Msg.deleteAt} carries + * {@code @Index(options = "expireAfterSeconds:0")}, so this is the exact mechanism Morphium's + * messaging uses to clean up. A single insert landing in the window between an invalidation and the + * next sweep tick left every already-stored message un-expirable - an unbounded {@code msg} + * collection. + * + *

Lives in the driver's own package to reach the package-private {@code runTtlSweepPass()}, + * which makes the sweep deterministic instead of racing the background scheduler. + */ +@Tag("inmemory") +public class TtlQueueInvalidationTest { + private final String db = "ttlinvalidationdb"; + private final String coll = "ttlinvalidationcoll"; + + /** + * A driver whose background sweep is effectively disabled: {@code expireCheck} is set before + * {@code connect()} (the period is fixed when the task is scheduled, so setting it afterwards + * has no effect), and the one unconditional tick 100ms after scheduling is waited out here. All + * sweeping in this test is then driven explicitly via {@link InMemoryDriver#runTtlSweepPass()}. + */ + private InMemoryDriver quiescentDriver() throws Exception { + InMemoryDriver drv = new InMemoryDriver(); + drv.setExpireCheck(3_600_000); + drv.connect(); + Thread.sleep(400); + return drv; + } + + @Test + void enqueueAfterInvalidationMustNotStripOlderDocsOfTheirExpiryTracking() throws Exception { + InMemoryDriver drv = quiescentDriver(); + drv.createIndex(db, coll, Doc.of("expiresAt", 1), Doc.of("name", "ttl_1", "expireAfterSeconds", 0)); + + // Five documents that are ALREADY due. Nothing removes them yet - the background sweep is + // quiesced and runTtlSweepPass() has not been called. + long past = System.currentTimeMillis() - 5_000L; + List> old = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + old.add(Doc.of("counter", i, "expiresAt", new Date(past))); + } + new InsertMongoCommand(drv).setDb(db).setColl(coll).setDocuments(old).execute(); + assertEquals(5, drv.find(db, coll, Doc.of(), null, null, 0, 0).size(), + "sanity: the five due documents must still be there - no sweep has run yet"); + + // Structural change that discards the queue while leaving the collection, its documents and + // its TTL index registration fully intact: a transaction commit (commitTransaction -> + // invalidateTtlQueue for every touched collection). The marker document deliberately has no + // TTL field, so it neither expires nor re-creates the queue on its own way in. + drv.startTransaction(false); + new InsertMongoCommand(drv).setDb(db).setColl(coll) + .setDocuments(List.of(Doc.of("marker", true))).execute(); + drv.commitTransaction(); + + // The race window #269 is about: one single insert of a TTL-bearing document before the + // next sweep tick. Pre-fix this created a fresh queue holding only this document. + new InsertMongoCommand(drv).setDb(db).setColl(coll) + .setDocuments(List.of(Doc.of("counter", 99, "expiresAt", new Date(past)))).execute(); + + drv.runTtlSweepPass(); + + List> remaining = drv.find(db, coll, Doc.of(), null, null, 0, 0); + assertEquals(1, remaining.size(), + "every due TTL document must have expired, not just the one inserted after the " + + "invalidation - still present: " + remaining); + assertTrue(Boolean.TRUE.equals(remaining.get(0).get("marker")), + "only the non-TTL marker document may survive, but found: " + remaining.get(0)); + } + + /** + * The bootstrap-on-miss added to {@code ttlEnqueue} scans the collection - which, at that + * point, already contains the very document being enqueued. It must not end up queued twice. + * A duplicate would not delete anything twice (the sweep re-checks each popped entry against + * the live document), but it would be popped and re-checked for nothing, so the + * {@code ttlEntriesChecked} counter is the observable that catches it. + */ + @Test + void bootstrapOnEnqueueMustNotDoubleQueueTheTriggeringDocument() throws Exception { + InMemoryDriver drv = quiescentDriver(); + drv.createIndex(db, coll, Doc.of("expiresAt", 1), Doc.of("name", "ttl_1", "expireAfterSeconds", 0)); + + long farFuture = System.currentTimeMillis() + 3_600_000L; + new InsertMongoCommand(drv).setDb(db).setColl(coll) + .setDocuments(List.of(Doc.of("counter", 0, "expiresAt", new Date(farFuture)))).execute(); + + drv.startTransaction(false); + new InsertMongoCommand(drv).setDb(db).setColl(coll) + .setDocuments(List.of(Doc.of("marker", true))).execute(); + drv.commitTransaction(); + + // Triggers the bootstrap-on-miss; "past" makes this document (and only this one) due. + long past = System.currentTimeMillis() - 5_000L; + new InsertMongoCommand(drv).setDb(db).setColl(coll) + .setDocuments(List.of(Doc.of("counter", 1, "expiresAt", new Date(past)))).execute(); + + long checkedBefore = drv.ttlEntriesChecked; + drv.runTtlSweepPass(); + long checked = drv.ttlEntriesChecked - checkedBefore; + + assertEquals(1, checked, + "the due document must be popped and checked exactly once - more means the " + + "bootstrap-on-miss queued it a second time on top of its own scan"); + assertEquals(2, drv.find(db, coll, Doc.of(), null, null, 0, 0).size(), + "only the due document may have been removed"); + } +} diff --git a/morphium-core/src/test/java/de/caluga/morphium/driver/wireprotocol/OpMsgDocumentSequenceTest.java b/morphium-core/src/test/java/de/caluga/morphium/driver/wireprotocol/OpMsgDocumentSequenceTest.java new file mode 100644 index 000000000..3ad81afe2 --- /dev/null +++ b/morphium-core/src/test/java/de/caluga/morphium/driver/wireprotocol/OpMsgDocumentSequenceTest.java @@ -0,0 +1,58 @@ +package de.caluga.morphium.driver.wireprotocol; + +import de.caluga.morphium.driver.Doc; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * OP_MSG kind-1 (document sequence) sections: mongorestore/mongoimport ship bulk inserts this + * way. morphium's own clients only ever send kind-0, so this path is exercised exclusively by + * foreign drivers talking to PoppyDB. + */ +@Tag("driver") +public class OpMsgDocumentSequenceTest { + + @Test + public void kind1SectionsParseAndStopAtMessageEnd() throws Exception { + OpMsg out = new OpMsg(); + out.setFirstDoc(Doc.of("insert", "kunden", "$db", "test")); + out.addDoc("documents", Doc.of("_id", 1, "name", "a")); + out.addDoc("documents", Doc.of("_id", 2, "name", "b")); + byte[] payload = out.getPayload(); + + // Simulate PoppyDB's zero-copy decode path: the backing array holds junk before the + // payload (offset) and further pipelined bytes after it - the parse must honor the + // declared message size instead of running to the end of the array. Before the fix it + // read into the trailing bytes ("unknown data type: 100"/garbage section ids). + byte[] buffer = new byte[8 + payload.length + 32]; + System.arraycopy(payload, 0, buffer, 8, payload.length); + Arrays.fill(buffer, 8 + payload.length, buffer.length, (byte) 0x64); + + OpMsg in = new OpMsg(); + in.setSize(payload.length + 16); // wire size includes the 16-byte header + in.parsePayload(buffer, 8); + + assertEquals("kunden", in.getFirstDoc().get("insert")); + assertNotNull(in.getDocuments()); + assertEquals(2, in.getDocuments().get("documents").size()); + assertEquals(2, ((Number) in.getDocuments().get("documents").get(1).get("_id")).intValue()); + } + + @Test + public void parseWithoutSizeFallsBackToArrayLength() throws Exception { + // Exact-array convention (OpCompressed unwrap paths call parsePayload without setSize) + OpMsg out = new OpMsg(); + out.setFirstDoc(Doc.of("ping", 1, "$db", "admin")); + byte[] payload = out.getPayload(); + + OpMsg in = new OpMsg(); + in.parsePayload(payload, 0); + + assertEquals(1, ((Number) in.getFirstDoc().get("ping")).intValue()); + assertNull(in.getDocuments()); + } +} diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/aggregationStages/AddFieldAndSetTests.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/aggregationStages/AddFieldAndSetTests.java index 5a6d4d10a..71253d09c 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/aggregationStages/AddFieldAndSetTests.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/aggregationStages/AddFieldAndSetTests.java @@ -17,6 +17,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("aggregation") public class AddFieldAndSetTests extends MultiDriverTestBase { @@ -35,9 +36,9 @@ public void addFieldsTest(Morphium morphium) throws Exception { List lst = agg.aggregate(); for (Student s : lst) { log.info(s.toString()); - assert (s.totalHomework != 0); - assert (s.totalQuiz != 0); - assert (s.totalScore != 0); + assertTrue((s.totalHomework != 0)); + assertTrue((s.totalQuiz != 0)); + assertTrue((s.totalScore != 0)); } } @@ -81,9 +82,9 @@ public void setTest(Morphium morphium) throws Exception { List lst = agg.aggregate(); for (Student s : lst) { log.info(s.toString()); - assert (s.totalHomework != 0); - assert (s.totalQuiz != 0); - assert (s.totalScore != 0); + assertTrue((s.totalHomework != 0)); + assertTrue((s.totalQuiz != 0)); + assertTrue((s.totalScore != 0)); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/aggregationStages/BucketAutoTests.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/aggregationStages/BucketAutoTests.java index bef0640c0..ba757642a 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/aggregationStages/BucketAutoTests.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/aggregationStages/BucketAutoTests.java @@ -19,6 +19,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("aggregation") @@ -35,7 +36,7 @@ public void bucketAutoTest(Morphium morphium) throws Exception { for (Map m : list) { log.info("Entry: " + m.toString()); - assert(m.get("count").equals(2)); + assertTrue((m.get("count").equals(2))); Double max = (Double)((Map) m.get("_id")).get("max"); Double min = (Double)((Map) m.get("_id")).get("min"); assertNotNull(min); @@ -44,7 +45,7 @@ public void bucketAutoTest(Morphium morphium) throws Exception { ; if (lastMax != null) { - assert(min.equals(lastMax)); + assertTrue((min.equals(lastMax))); } lastMax = max; diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/aggregationStages/BucketTests.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/aggregationStages/BucketTests.java index 1e1c657fd..bc515b00a 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/aggregationStages/BucketTests.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/aggregationStages/BucketTests.java @@ -17,6 +17,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("aggregation") public class BucketTests extends MultiDriverTestBase { @@ -47,10 +48,10 @@ public void bucketTest(Morphium morphium) throws Exception { assertNotNull(a.artists); ; - assert (a.artists.size() > 0); - assert (a.count == a.artists.size()); + assertTrue((a.artists.size() > 0)); + assertTrue((a.count == a.artists.size())); for (Artist artist : a.artists) { - assert (a.id <= artist.yearBorn); + assertTrue((a.id <= artist.yearBorn)); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/aggregationStages/GeoNearTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/aggregationStages/GeoNearTest.java index 72c718515..f3f6bfa6c 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/aggregationStages/GeoNearTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/aggregationStages/GeoNearTest.java @@ -16,6 +16,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("aggregation") @Tag("external") // Requires MongoDB - $geoNear not supported by InMemoryDriver @@ -42,7 +43,7 @@ public void testGeoNear(Morphium morphium) throws Exception { // Verify we have the expected number of documents long count = morphium.createQueryFor(Place.class).countAll(); - assert count == 6 : "Expected 6 places but found " + count; + assertTrue(count == 6, () -> String.valueOf("Expected 6 places but found " + count)); Aggregator agg = morphium.createAggregator(Place.class, Map.class); agg.geoNear(UtilsMap.of(Aggregator.GeoNearFields.near, (Object) new Point(-73.98142, 40.71782), @@ -52,13 +53,13 @@ public void testGeoNear(Morphium morphium) throws Exception { ); List> result = agg.aggregateMap(); - assert (result.size() == 3); + assertTrue((result.size() == 3)); for (Map m : result) { log.info("Result: " + m.toString()); - assert (m.get("category").equals("Stadiums")); - assert (m.get("dist") instanceof Map); - assert (((Map) m.get("dist")).get("calculated") instanceof Double); + assertTrue((m.get("category").equals("Stadiums"))); + assertTrue((m.get("dist") instanceof Map)); + assertTrue((((Map) m.get("dist")).get("calculated") instanceof Double)); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/aggregationStages/LookupTests.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/aggregationStages/LookupTests.java index 07b8062c2..c199c03ba 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/aggregationStages/LookupTests.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/aggregationStages/LookupTests.java @@ -17,6 +17,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("aggregation") public class LookupTests extends MultiDriverTestBase { @@ -45,9 +46,9 @@ public void singleEqualityJoinTest(Morphium morphium) throws Exception { ; if (m.get("_id").equals(4)) { - assert(((List) m.get("inventory_docs")).size() == 0); + assertTrue((((List) m.get("inventory_docs")).size() == 0)); } else { - assert(((List) m.get("inventory_docs")).size() == 1); + assertTrue((((List) m.get("inventory_docs")).size() == 1)); } } } @@ -88,11 +89,11 @@ public void multipleConditionAndPipelines(Morphium morphium) throws Exception { if (m.get("_id").equals(1)) { //should be two possible warehouses - assert(((List) m.get("stock_data")).size() == 2); + assertTrue((((List) m.get("stock_data")).size() == 2)); } else if (m.get("_id").equals(5)) { - assert(((List) m.get("stock_data")).size() == 0); //not available + assertTrue((((List) m.get("stock_data")).size() == 0)); //not available } else { - assert(((List) m.get("stock_data")).size() == 1); + assertTrue((((List) m.get("stock_data")).size() == 1)); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AdditionalDataTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AdditionalDataTest.java index db055df5b..b2eeab6c3 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AdditionalDataTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AdditionalDataTest.java @@ -13,6 +13,7 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -43,9 +44,9 @@ public void additionalData(Morphium morphium) throws Exception { System.out.println("Stored some additional data!"); AdditionalDataEntity d2 = TestUtils.waitForObject( () -> morphium.findById(AdditionalDataEntity.class, d.getMorphiumId())); assertNotNull(d2.getAdditionals()); - assert (d2.getAdditionals().get("102-92-93").equals(3234)); - assert (((Map) d2.getAdditionals().get("test")).get("tst").equals("tst")); - assert (d2.getAdditionals().get("_id") == null); + assertTrue((d2.getAdditionals().get("102-92-93").equals(3234))); + assertTrue((((Map) d2.getAdditionals().get("test")).get("tst").equals("tst"))); + assertTrue((d2.getAdditionals().get("_id") == null)); } } @@ -100,7 +101,7 @@ public void additionalDataNullTest(Morphium morphium) throws Exception { morphium.store(d); AdditionalDataEntity d2 = TestUtils.waitForObject( () -> morphium.findById(AdditionalDataEntity.class, d.getMorphiumId())); assertNotNull(d2); - assert (d2.getAdditionals() == null || d2.getAdditionals().isEmpty()); + assertTrue((d2.getAdditionals() == null || d2.getAdditionals().isEmpty())); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AggregationExpQuery.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AggregationExpQuery.java index c4df26cb1..a7539e8d1 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AggregationExpQuery.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AggregationExpQuery.java @@ -11,6 +11,7 @@ import org.junit.jupiter.params.provider.MethodSource; import java.util.List; +import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("core") public class AggregationExpQuery extends MultiDriverTestBase { @@ -24,7 +25,7 @@ public void testQuery(Morphium morphium) throws Exception { q.expr(Expr.gt(Expr.field(UncachedObject.Fields.counter), Expr.intExpr(50))); log.debug(Utils.toJsonString(q.toQueryObject())); List lst = q.asList(); - assert (lst.size() == 49) : "Size wrong: " + lst.size(); // 0-based counters: 51-99 = 49 objects + assertTrue(lst.size() == 49, "Size wrong: " + lst.size()); // 0-based counters: 51-99 = 49 objects // Update all objects with random dval values @@ -39,10 +40,10 @@ public void testQuery(Morphium morphium) throws Exception { q = q.q().expr(Expr.gt(Expr.field(UncachedObject.Fields.counter), Expr.field(UncachedObject.Fields.dval))); lst = q.asList(); - assert (lst.size() > 0); - assert (lst.size() < 100); + assertTrue((lst.size() > 0)); + assertTrue((lst.size() < 100)); for (UncachedObject u : lst) { - assert (u.getCounter() > u.getDval()); + assertTrue((u.getCounter() > u.getDval())); } } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AggregationExprTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AggregationExprTest.java index 031e5a78d..f01a7e02a 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AggregationExprTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AggregationExprTest.java @@ -17,6 +17,7 @@ import static de.caluga.morphium.aggregation.Expr.*; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; @SuppressWarnings("unchecked") @Testable @@ -29,7 +30,7 @@ public void testAbs() { Object o = abs(intExpr(-12)).toQueryObject(); String s = Utils.toJsonString(o); log.info("Json: " + s); - assert(s.equals("{ \"$abs\" : -12 } ")); + assertTrue((s.equals("{ \"$abs\" : -12 } "))); } @Test @@ -66,213 +67,213 @@ public void testReplaceAll() { @Test public void testField() { Expr fld = field("test"); - assert(fld.toQueryObject().equals("$test")); + assertTrue((fld.toQueryObject().equals("$test"))); } @Test public void dateTest() { Expr dt = date(new Date()); - assert(dt.toQueryObject() instanceof Date); + assertTrue((dt.toQueryObject() instanceof Date)); } @Test public void testDoubleExpr() { Expr e = doubleExpr(123.4); - assert(e.toQueryObject().equals(123.4)); + assertTrue((e.toQueryObject().equals(123.4))); } @Test public void testIntExpr() { Expr e = intExpr(123); - assert(e.toQueryObject().equals(123)); + assertTrue((e.toQueryObject().equals(123))); } @Test public void testBool() { Expr e = bool(true); - assert(e.toQueryObject().equals(true)); + assertTrue((e.toQueryObject().equals(true))); } @Test public void testArrayExpr() { Expr e = arrayExpr(intExpr(1), string("test")); - assert(e.toQueryObject() instanceof List); - assert(((List) e.toQueryObject()).get(0).equals(1)); + assertTrue((e.toQueryObject() instanceof List)); + assertTrue((((List) e.toQueryObject()).get(0).equals(1))); } @Test public void testString() { Expr e = string("test"); - assert(e.toQueryObject().equals("test")); + assertTrue((e.toQueryObject().equals("test"))); } @Test public void testAdd() { Expr e = add(field("tst"), intExpr(42)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$add\" : [ \"$tst\", 42] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$add\" : [ \"$tst\", 42] } "))); } @Test public void testCeil() { Expr e = ceil(doubleExpr(42.42)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$ceil\" : 42.42 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$ceil\" : 42.42 } "))); } @Test public void testDivide() { Expr e = divide(doubleExpr(42), doubleExpr(12)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$divide\" : [ 42.0, 12.0] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$divide\" : [ 42.0, 12.0] } "))); } @Test public void testExp() { Expr e = exp(doubleExpr(42)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$exp\" : 42.0 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$exp\" : 42.0 } "))); } @Test public void testFloor() { Expr e = floor(doubleExpr(42)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$floor\" : 42.0 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$floor\" : 42.0 } "))); } @Test public void testLn() { Expr e = ln(doubleExpr(42)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$ln\" : 42.0 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$ln\" : 42.0 } "))); } @Test public void testLog() { Expr e = log(doubleExpr(42), intExpr(10)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$log\" : [ 42.0, 10] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$log\" : [ 42.0, 10] } "))); } @Test public void testLog10() { Expr e = log10(doubleExpr(42)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$log10\" : 42.0 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$log10\" : 42.0 } "))); } @Test public void testMod() { Expr e = mod(doubleExpr(42), doubleExpr(12)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$mod\" : [ 42.0, 12.0] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$mod\" : [ 42.0, 12.0] } "))); } @Test public void testMultiply() { Expr e = multiply(doubleExpr(42), doubleExpr(12)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$multiply\" : [ 42.0, 12.0] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$multiply\" : [ 42.0, 12.0] } "))); } @Test public void testPow() { Expr e = pow(doubleExpr(42), doubleExpr(12)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$pow\" : [ 42.0, 12.0] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$pow\" : [ 42.0, 12.0] } "))); } @Test public void testRound() { Expr e = round(doubleExpr(42)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$round\" : 42.0 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$round\" : 42.0 } "))); } @Test public void testSqrt() { Expr e = sqrt(doubleExpr(42)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$sqrt\" : 42.0 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$sqrt\" : 42.0 } "))); } @Test public void testSubtract() { Expr e = subtract(doubleExpr(42), doubleExpr(12)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$substract\" : [ 42.0, 12.0] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$substract\" : [ 42.0, 12.0] } "))); } @Test public void testTrunc() { Expr e = trunc(doubleExpr(42.23), doubleExpr(1)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$trunc\" : [ 42.23, 1.0] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$trunc\" : [ 42.23, 1.0] } "))); } @Test public void testArrayElemAt() { Expr e = arrayElemAt(arrayExpr(intExpr(1), intExpr(41)), intExpr(1)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$arrayElemAt\" : [ [ 1, 41], 1] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$arrayElemAt\" : [ [ 1, 41], 1] } "))); } @Test public void testArrayToObject() { Expr e = arrayToObject(arrayExpr(string("value"), intExpr(42))); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$arrayToObject\" : [ [ \"value\", 42]] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$arrayToObject\" : [ [ \"value\", 42]] } "))); } @Test public void testConcatArrays() { Expr e = concatArrays(arrayExpr(string("value"), intExpr(42)), arrayExpr(intExpr(1234))); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$concatArrays\" : [ [ \"value\", 42], [ 1234]] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$concatArrays\" : [ [ \"value\", 42], [ 1234]] } "))); } @Test public void testFilter() { Expr e = filter(arrayExpr(string("value"), intExpr(42)), "name", gt(field("tst"), intExpr(40))); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$filter\" : { \"input\" : [ \"value\", 42], \"as\" : \"name\", \"cond\" : { \"$gt\" : [ \"$tst\", 40] } } } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$filter\" : { \"input\" : [ \"value\", 42], \"as\" : \"name\", \"cond\" : { \"$gt\" : [ \"$tst\", 40] } } } "))); } @Test public void testFirst() { Expr e = first(arrayExpr(string("value"), intExpr(42))); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$first\" : [ \"value\", 42] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$first\" : [ \"value\", 42] } "))); } @Test public void testIn() { Expr e = in(field("test"), arrayExpr(string("value"), intExpr(42))); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$in\" : [ \"$test\", [ \"value\", 42]] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$in\" : [ \"$test\", [ \"value\", 42]] } "))); } @Test public void testIndexOfArray() { Expr e = indexOfArray(arrayExpr(string("value"), intExpr(42)), string("value"), intExpr(0), null); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$indexOfArray\" : [ [ \"value\", 42], \"value\", 0] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$indexOfArray\" : [ [ \"value\", 42], \"value\", 0] } "))); } @Test public void testIsArray() { Expr e = isArray(arrayExpr(string("value"), intExpr(42))); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$isArray\" : [ [ \"value\", 42]] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$isArray\" : [ [ \"value\", 42]] } "))); } @Test public void testLast() { Expr e = last(arrayExpr(string("value"), intExpr(42))); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$last\" : [ \"value\", 42] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$last\" : [ \"value\", 42] } "))); } @Test @@ -281,21 +282,21 @@ public void testMap() { log.info(Utils.toJsonString(e.toQueryObject())); // real MongoDB only accepts the document form {input, as, in} - the old array // serialization was rejected by the server (#255) - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$map\" : { \"input\" : [ \"value\", 42], \"as\" : \"name\", \"in\" : { \"$gt\" : [ \"$name\", 42] } } } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$map\" : { \"input\" : [ \"value\", 42], \"as\" : \"name\", \"in\" : { \"$gt\" : [ \"$name\", 42] } } } "))); } @Test public void testObjectToArray() { Expr e = objectToArray(doc(UtilsMap.of("_id", (Object) 12, "test", "value"))); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$objectToArray\" : { \"_id\" : 12, \"test\" : \"value\" } } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$objectToArray\" : { \"_id\" : 12, \"test\" : \"value\" } } "))); } @Test public void testRange() { Expr e = range(intExpr(12), intExpr(42), intExpr(2)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$range\" : [ 12, 42, 2] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$range\" : [ 12, 42, 2] } "))); } @Test @@ -309,29 +310,28 @@ public void testReduce() { ) ); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString( - e.toQueryObject()).equals("{ \"$reduce\" : { \"input\" : [ 1, 2, 3, 4], \"initialValue\" : \"\", \"in\" : { \"sum\" : { \"$add\" : [ \"$$value.sum\", \"$$this\"] } , \"product\" : { \"$multiply\" : [ \"$$value.product\", \"$$this\"] } } } } ")); + assertTrue((Utils.toJsonString( e.toQueryObject()).equals("{ \"$reduce\" : { \"input\" : [ 1, 2, 3, 4], \"initialValue\" : \"\", \"in\" : { \"sum\" : { \"$add\" : [ \"$$value.sum\", \"$$this\"] } , \"product\" : { \"$multiply\" : [ \"$$value.product\", \"$$this\"] } } } } "))); } @Test public void testReverseArray() { Expr e = reverseArray(arrayExpr(intExpr(42), intExpr(2))); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$reverseArray\" : [ 42, 2] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$reverseArray\" : [ 42, 2] } "))); } @Test public void testSize() { Expr e = size(arrayExpr(intExpr(42), intExpr(2))); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$size\" : [ 42, 2] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$size\" : [ 42, 2] } "))); } @Test public void testSlice() { Expr e = slice(arrayExpr(intExpr(42), intExpr(4), intExpr(12), intExpr(2)), intExpr(1), intExpr(2)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$slice\" : [ [ 42, 4, 12, 2], 1, 2] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$slice\" : [ [ 42, 4, 12, 2], 1, 2] } "))); } @Test @@ -342,8 +342,7 @@ public void testZip() { inputs.add(arrayExpr(intExpr(782), intExpr(1234), intExpr(-5), intExpr(6))); Expr e = zip(inputs, bool(false), arrayExpr(intExpr(122), intExpr(3), intExpr(17), intExpr(9))); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString( - e.toQueryObject()).equals("{ \"$zip\" : { \"inputs\" : [ [ 42, 4, 12, 2], [ 122, 3, 17, 9], [ 782, 1234, -5, 6]], \"useLongestLength\" : false, \"defaults\" : [ 122, 3, 17, 9] } } ")); + assertTrue((Utils.toJsonString( e.toQueryObject()).equals("{ \"$zip\" : { \"inputs\" : [ [ 42, 4, 12, 2], [ 122, 3, 17, 9], [ 782, 1234, -5, 6]], \"useLongestLength\" : false, \"defaults\" : [ 122, 3, 17, 9] } } "))); } @Test @@ -353,8 +352,7 @@ public void testAnd() { anyElementTrue(bool(false), bool(true), field("checker")) ); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString( - e.toQueryObject()).equals("{ \"$and\" : [ { \"$gte\" : [ 12, \"$test\"] } , { \"$lt\" : [ \"$count\", 12.2] } , { \"$anyElementsTrue\" : [ false, true, \"$checker\"] } ] } ")); + assertTrue((Utils.toJsonString( e.toQueryObject()).equals("{ \"$and\" : [ { \"$gte\" : [ 12, \"$test\"] } , { \"$lt\" : [ \"$count\", 12.2] } , { \"$anyElementsTrue\" : [ false, true, \"$checker\"] } ] } "))); } @Test @@ -364,374 +362,371 @@ public void testOr() { anyElementTrue(bool(false), bool(true), field("checker")) ); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString( - e.toQueryObject()).equals("{ \"$or\" : [ { \"$gte\" : [ 12, \"$test\"] } , { \"$lt\" : [ \"$count\", 12.2] } , { \"$anyElementsTrue\" : [ false, true, \"$checker\"] } ] } ")); + assertTrue((Utils.toJsonString( e.toQueryObject()).equals("{ \"$or\" : [ { \"$gte\" : [ 12, \"$test\"] } , { \"$lt\" : [ \"$count\", 12.2] } , { \"$anyElementsTrue\" : [ false, true, \"$checker\"] } ] } "))); } @Test public void testNot() { Expr e = not(lte(field("count"), doubleExpr(12.3))); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$not\" : { \"$lte\" : [ \"$count\", 12.3] } } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$not\" : { \"$lte\" : [ \"$count\", 12.3] } } "))); } @Test public void testCmp() { Expr e = cmp(intExpr(12), doubleExpr(21.2)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$cmp\" : [ 12, 21.2] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$cmp\" : [ 12, 21.2] } "))); } @Test public void testEq() { Expr e = eq(intExpr(12), doubleExpr(21.2)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$eq\" : [ 12, 21.2] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$eq\" : [ 12, 21.2] } "))); } @Test public void testNe() { Expr e = ne(intExpr(12), doubleExpr(21.2)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$ne\" : [ 12, 21.2] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$ne\" : [ 12, 21.2] } "))); } @Test public void testGt() { Expr e = gt(intExpr(12), doubleExpr(21.2)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$gt\" : [ 12, 21.2] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$gt\" : [ 12, 21.2] } "))); } @Test public void testLt() { Expr e = lt(intExpr(12), doubleExpr(21.2)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$lt\" : [ 12, 21.2] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$lt\" : [ 12, 21.2] } "))); } @Test public void testGte() { Expr e = gte(intExpr(12), doubleExpr(21.2)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$gte\" : [ 12, 21.2] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$gte\" : [ 12, 21.2] } "))); } @Test public void testLte() { Expr e = lte(intExpr(12), doubleExpr(21.2)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$lte\" : [ 12, 21.2] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$lte\" : [ 12, 21.2] } "))); } @Test public void testCond() { Expr e = cond(lt(field("created"), string("now")), intExpr(12), doubleExpr(21.2)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$cond\" : [ { \"$lt\" : [ \"$created\", \"now\"] } , 12, 21.2] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$cond\" : [ { \"$lt\" : [ \"$created\", \"now\"] } , 12, 21.2] } "))); } @Test public void testIfNull() { Expr e = ifNull(field("testField"), field("otherField")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$ifNull\" : [ \"$testField\", \"$otherField\"] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$ifNull\" : [ \"$testField\", \"$otherField\"] } "))); } @Test public void testSwitchExpr() { Expr e = switchExpr(UtilsMap.of(Expr.gt(field("test"), intExpr(12)), string("teststring")), intExpr(12)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$switch\" : { \"branches\" : [ { \"case\" : { \"$gt\" : [ \"$test\", 12] } , \"then\" : \"teststring\" } ], \"default\" : 12 } } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$switch\" : { \"branches\" : [ { \"case\" : { \"$gt\" : [ \"$test\", 12] } , \"then\" : \"teststring\" } ], \"default\" : 12 } } "))); } @Test public void testFunction() { Expr e = function("code", Expr.field("fieldArg")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$function\" : { \"body\" : \"code\", \"args\" : \"$fieldArg\", \"lang\" : \"js\" } } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$function\" : { \"body\" : \"code\", \"args\" : \"$fieldArg\", \"lang\" : \"js\" } } "))); } @Test public void testAccumulator() { Expr e = accumulator("init code here", Expr.field("InitArgs"), "Accumulating code", Expr.string("accArgs"), "Merged code", "finalizeCode"); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString( - e.toQueryObject()).equals("{ \"$accumulator\" : { \"init\" : \"init code here\", \"initArgs\" : \"$InitArgs\", \"accumulate\" : \"Accumulating code\", \"accumulateArgs\" : \"accArgs\", \"merge\" : \"Merged code\", \"finalize\" : \"finalizeCode\", \"lang\" : \"js\" } } ")); + assertTrue((Utils.toJsonString( e.toQueryObject()).equals("{ \"$accumulator\" : { \"init\" : \"init code here\", \"initArgs\" : \"$InitArgs\", \"accumulate\" : \"Accumulating code\", \"accumulateArgs\" : \"accArgs\", \"merge\" : \"Merged code\", \"finalize\" : \"finalizeCode\", \"lang\" : \"js\" } } "))); } @Test public void testBinarySize() { Expr e = binarySize(field("fld")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$binarySize\" : \"$fld\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$binarySize\" : \"$fld\" } "))); } @Test public void testBsonSize() { Expr e = bsonSize(field("fld")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$bsonSize\" : \"$fld\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$bsonSize\" : \"$fld\" } "))); } @Test public void testDateFromParts() { Expr e = dateFromParts(intExpr(2020), intExpr(8), intExpr(12), intExpr(22), intExpr(34), intExpr(29), intExpr(123), string("CET")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString( - e.toQueryObject()).equals("{ \"$dateFromParts\" : { \"year\" : 2020, \"month\" : 8, \"day\" : 12, \"hour\" : 22, \"minute\" : 34, \"second\" : 29, \"millisecond\" : 123, \"timezone\" : \"CET\" } } ")); + assertTrue((Utils.toJsonString( e.toQueryObject()).equals("{ \"$dateFromParts\" : { \"year\" : 2020, \"month\" : 8, \"day\" : 12, \"hour\" : 22, \"minute\" : 34, \"second\" : 29, \"millisecond\" : 123, \"timezone\" : \"CET\" } } "))); } @Test public void testDateFromString() { Expr e = dateFromString(field("fld"), null, null, null, null); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$dateFromString\" : { \"dateString\" : \"$fld\" } } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$dateFromString\" : { \"dateString\" : \"$fld\" } } "))); } @Test public void testDateToParts() { Expr e = dateToParts(field("fld"), null, false); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$dateToParts\" : { \"date\" : \"$fld\", \"iso8601\" : false } } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$dateToParts\" : { \"date\" : \"$fld\", \"iso8601\" : false } } "))); } @Test public void testDateToString() { Expr e = dateToString(field("fld"), null, null, Expr.string("no date")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$dateToString\" : { \"dateString\" : \"$fld\", \"onNull\" : \"no date\" } } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$dateToString\" : { \"dateString\" : \"$fld\", \"onNull\" : \"no date\" } } "))); } @Test public void testDayOfMonth() { Expr e = dayOfMonth(field("fld")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$dayOfMonth\" : \"$fld\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$dayOfMonth\" : \"$fld\" } "))); } @Test public void testDayOfWeek() { Expr e = dayOfWeek(field("fld")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$dayOfWeek\" : \"$fld\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$dayOfWeek\" : \"$fld\" } "))); } @Test public void testDayOfYear() { Expr e = dayOfYear(field("fld")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$dayOfYear\" : \"$fld\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$dayOfYear\" : \"$fld\" } "))); } @Test public void testHour() { Expr e = hour(field("fld")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$hour\" : \"$fld\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$hour\" : \"$fld\" } "))); } @Test public void testIsoDayOfWeek() { Expr e = isoDayOfWeek(field("fld")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$isoDayOfWeek\" : \"$fld\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$isoDayOfWeek\" : \"$fld\" } "))); } @Test public void testIsoWeek() { Expr e = isoWeek(field("fld")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$isoWeek\" : \"$fld\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$isoWeek\" : \"$fld\" } "))); } @Test public void testIsoWeekYear() { Expr e = isoWeekYear(field("fld")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$isoWeekYear\" : \"$fld\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$isoWeekYear\" : \"$fld\" } "))); } @Test public void testMillisecond() { Expr e = millisecond(field("fld")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$millisecond\" : \"$fld\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$millisecond\" : \"$fld\" } "))); } @Test public void testMinute() { Expr e = minute(field("fld")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$minute\" : \"$fld\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$minute\" : \"$fld\" } "))); } @Test public void testMonth() { Expr e = month(field("fld")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$month\" : \"$fld\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$month\" : \"$fld\" } "))); } @Test public void testSecond() { Expr e = second(field("fld")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$second\" : \"$fld\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$second\" : \"$fld\" } "))); } @Test public void testToDate() { Expr e = toDate(field("fld")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$toDate\" : \"$fld\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$toDate\" : \"$fld\" } "))); } @Test public void testWeek() { Expr e = week(field("fld")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$week\" : \"$fld\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$week\" : \"$fld\" } "))); } @Test public void testYear() { Expr e = year(field("fld")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$year\" : \"$fld\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$year\" : \"$fld\" } "))); } @Test public void testLiteral() { Expr e = literal(string("$$fieldname")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$literal\" : \"$$fieldname\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$literal\" : \"$$fieldname\" } "))); } @Test public void testMergeObjects() { Expr e = mergeObjects(field("fld"), field("doc2"), mapExpr(UtilsMap.of("test", intExpr(123), "value", string("val")))); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$mergeObjects\" : [ \"$fld\", \"$doc2\", { \"test\" : 123, \"value\" : \"val\" } ] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$mergeObjects\" : [ \"$fld\", \"$doc2\", { \"test\" : 123, \"value\" : \"val\" } ] } "))); } @Test public void testTestMergeObjects() { Expr e = mergeObjects(field("fld")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$mergeObjects\" : \"$fld\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$mergeObjects\" : \"$fld\" } "))); } @Test public void testAllElementsTrue() { Expr e = allElementsTrue(field("fld"), bool(true), field("other")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$allElementsTrue\" : [ \"$fld\", true, \"$other\"] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$allElementsTrue\" : [ \"$fld\", true, \"$other\"] } "))); } @Test public void testAnyElementTrue() { Expr e = anyElementTrue(field("fld"), bool(true), field("other")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$anyElementsTrue\" : [ \"$fld\", true, \"$other\"] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$anyElementsTrue\" : [ \"$fld\", true, \"$other\"] } "))); } @Test public void testSetDifference() { Expr e = setDifference(field("fld"), field("other")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$setDifference\" : [ \"$fld\", \"$other\"] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$setDifference\" : [ \"$fld\", \"$other\"] } "))); } @Test public void testSetEquals() { Expr e = setEquals(field("fld"), field("other")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$setEquals\" : [ \"$fld\", \"$other\"] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$setEquals\" : [ \"$fld\", \"$other\"] } "))); } @Test public void testSetIntersection() { Expr e = setIntersection(field("fld"), field("other")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$setIntersection\" : [ \"$fld\", \"$other\"] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$setIntersection\" : [ \"$fld\", \"$other\"] } "))); } @Test public void testSetIsSubset() { Expr e = setIsSubset(field("fld"), field("other")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$setIsSubset\" : [ \"$fld\", \"$other\"] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$setIsSubset\" : [ \"$fld\", \"$other\"] } "))); } @Test public void testSetUnion() { Expr e = setUnion(field("fld"), field("other"), arrayExpr(intExpr(12), intExpr(22), intExpr(10))); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$setUnion\" : [ \"$fld\", \"$other\", [ 12, 22, 10]] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$setUnion\" : [ \"$fld\", \"$other\", [ 12, 22, 10]] } "))); } @Test public void testConcat() { Expr e = concat(field("fld"), field("other"), arrayExpr(intExpr(12), intExpr(22), intExpr(10))); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$concat\" : [ \"$fld\", \"$other\", [ 12, 22, 10]] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$concat\" : [ \"$fld\", \"$other\", [ 12, 22, 10]] } "))); } @Test public void testIndexOfBytes() { Expr e = indexOfBytes(string("String to search in for substring"), string("substring"), intExpr(0), null); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$indexOfBytes\" : [ \"String to search in for substring\", \"substring\", 0] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$indexOfBytes\" : [ \"String to search in for substring\", \"substring\", 0] } "))); } @Test public void testIndexOfCP() { Expr e = indexOfCP(string("String to search in for substring"), string("substring"), intExpr(0), null); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$indexOfCP\" : [ \"String to search in for substring\", \"substring\", 0] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$indexOfCP\" : [ \"String to search in for substring\", \"substring\", 0] } "))); } @Test public void testLtrim() { Expr e = ltrim(string("string to trim"), string(" ")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$ltrim\" : { \"input\" : \"string to trim\", \"chars\" : \" \" } } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$ltrim\" : { \"input\" : \"string to trim\", \"chars\" : \" \" } } "))); } @Test public void testRtrim() { Expr e = rtrim(string("string to trim"), string(" ")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$rtrim\" : { \"input\" : \"string to trim\", \"chars\" : \" \" } } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$rtrim\" : { \"input\" : \"string to trim\", \"chars\" : \" \" } } "))); } @Test public void testToLower() { Expr e = toLower(string("text to lower")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$toLower\" : \"text to lower\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$toLower\" : \"text to lower\" } "))); } @Test public void testToStr() { Expr e = toStr(field("testfield")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$toString\" : \"$testfield\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$toString\" : \"$testfield\" } "))); } @Test public void testTrim() { Expr e = trim(string("string to trim"), string(" ")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$trim\" : { \"input\" : \"string to trim\", \"chars\" : \" \" } } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$trim\" : { \"input\" : \"string to trim\", \"chars\" : \" \" } } "))); } @Test public void testToUpper() { Expr e = toUpper(string("text to upper")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$toUpper\" : \"text to upper\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$toUpper\" : \"text to upper\" } "))); } @Test @@ -742,70 +737,70 @@ public void testMeta() { public void testSin() { Expr e = sin(field("testField")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$sin\" : \"$testField\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$sin\" : \"$testField\" } "))); } @Test public void testCos() { Expr e = cos(intExpr(23)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$cos\" : 23 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$cos\" : 23 } "))); } @Test public void testTan() { Expr e = tan(intExpr(23)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$tan\" : 23 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$tan\" : 23 } "))); } @Test public void testAsin() { Expr e = asin(intExpr(23)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$asin\" : 23 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$asin\" : 23 } "))); } @Test public void testAcos() { Expr e = acos(intExpr(23)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$acos\" : 23 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$acos\" : 23 } "))); } @Test public void testAtan() { Expr e = atan(intExpr(23)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$atan\" : 23 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$atan\" : 23 } "))); } @Test public void testAtan2() { Expr e = atan2(intExpr(23), intExpr(2)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$atan2\" : [ 23, 2] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$atan2\" : [ 23, 2] } "))); } @Test public void testAsinh() { Expr e = asinh(intExpr(23)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$asinh\" : 23 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$asinh\" : 23 } "))); } @Test public void testAcosh() { Expr e = acosh(intExpr(23)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$acosh\" : 23 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$acosh\" : 23 } "))); } @Test public void testAtanh() { Expr e = atanh(intExpr(23), intExpr(1)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$atanh\" : [ 23, 1] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$atanh\" : [ 23, 1] } "))); } @Test @@ -813,28 +808,28 @@ public void testDegreesToRadian() { Expr e = degreesToRadian(intExpr(230)); log.info(Utils.toJsonString(e.toQueryObject())); // the operator was misspelled - MongoDB knows only $degreesToRadians (#255) - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$degreesToRadians\" : 230 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$degreesToRadians\" : 230 } "))); } @Test public void testRadiansToDegrees() { Expr e = radiansToDegrees(doubleExpr(1.28)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$radiansToDegrees\" : 1.28 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$radiansToDegrees\" : 1.28 } "))); } @Test public void testConvert() { Expr e = convert(intExpr(230), intExpr(2), string("error"), string("null")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$convert\" : { \"input\" : 230, \"to\" : 2, \"onError\" : \"error\", \"onNull\" : \"null\" } } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$convert\" : { \"input\" : 230, \"to\" : 2, \"onError\" : \"error\", \"onNull\" : \"null\" } } "))); } @Test public void testConvert2() { Expr e = convert(intExpr(230), intExpr(2)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$convert\" : { \"input\" : 230, \"to\" : 2 } } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$convert\" : { \"input\" : 230, \"to\" : 2 } } "))); } @@ -842,7 +837,7 @@ public void testConvert2() { public void testConvert3() { Expr e = convert(intExpr(230), intExpr(2), string("error")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$convert\" : { \"input\" : 230, \"to\" : 2, \"onError\" : \"error\" } } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$convert\" : { \"input\" : 230, \"to\" : 2, \"onError\" : \"error\" } } "))); } @Test @@ -858,161 +853,161 @@ public void testDateFromParts2() { public void testIsNumber() { Expr e = isNumber(doubleExpr(1.28)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$isNumber\" : 1.28 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$isNumber\" : 1.28 } "))); } @Test public void testToBool() { Expr e = toBool(intExpr(1)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$toBool\" : 1 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$toBool\" : 1 } "))); } @Test public void testToDecimal() { Expr e = toDecimal(intExpr(1)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$toDecimal\" : 1 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$toDecimal\" : 1 } "))); } @Test public void testToDouble() { Expr e = toDouble(intExpr(1)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$toDouble\" : 1 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$toDouble\" : 1 } "))); } @Test public void testToInt() { Expr e = toInt(intExpr(1)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$toInt\" : 1 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$toInt\" : 1 } "))); } @Test public void testToLong() { Expr e = toLong(intExpr(1)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$toLong\" : 1 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$toLong\" : 1 } "))); } @Test public void testToObjectId() { Expr e = toObjectId(intExpr(1)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$toObjectId\" : 1 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$toObjectId\" : 1 } "))); } @Test public void testType() { Expr e = type(intExpr(1)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$type\" : 1 } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$type\" : 1 } "))); } @Test public void testAddToSet() { Expr e = addToSet(field("destinationField")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$addToSet\" : \"$destinationField\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$addToSet\" : \"$destinationField\" } "))); } @Test public void testAvg() { Expr e = avg(field("fld"), intExpr(12), doubleExpr(12.2)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$avg\" : [ \"$fld\", 12, 12.2] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$avg\" : [ \"$fld\", 12, 12.2] } "))); } @Test public void testTestAvg() { Expr e = avg(field("field")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$avg\" : \"$field\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$avg\" : \"$field\" } "))); } @Test public void testMax() { Expr e = max(field("fld"), intExpr(12), doubleExpr(12.2)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$max\" : [ \"$fld\", 12, 12.2] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$max\" : [ \"$fld\", 12, 12.2] } "))); } @Test public void testTestMax() { Expr e = max(field("field")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$max\" : \"$field\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$max\" : \"$field\" } "))); } @Test public void testMin() { Expr e = min(field("fld"), intExpr(12), doubleExpr(12.2)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$min\" : [ \"$fld\", 12, 12.2] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$min\" : [ \"$fld\", 12, 12.2] } "))); } @Test public void testTestMin() { Expr e = min(field("field")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$min\" : \"$field\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$min\" : \"$field\" } "))); } @Test public void testPush() { Expr e = push(field("field")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$push\" : \"$field\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$push\" : \"$field\" } "))); } @Test public void testStdDevPop() { Expr e = stdDevPop(field("field")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$stdDevPop\" : \"$field\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$stdDevPop\" : \"$field\" } "))); } @Test public void testTestStdDevPop() { Expr e = stdDevPop(field("fld"), intExpr(12), doubleExpr(12.2)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$stdDevPop\" : [ \"$fld\", 12, 12.2] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$stdDevPop\" : [ \"$fld\", 12, 12.2] } "))); } @Test public void testStdDevSamp() { Expr e = stdDevSamp(field("field")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$stdDevSamp\" : \"$field\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$stdDevSamp\" : \"$field\" } "))); } @Test public void testTestStdDevSamp() { Expr e = stdDevSamp(field("fld"), intExpr(12), doubleExpr(12.2)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$stdDevSamp\" : [ \"$fld\", 12, 12.2] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$stdDevSamp\" : [ \"$fld\", 12, 12.2] } "))); } @Test public void testSum() { Expr e = sum(field("field")); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$sum\" : \"$field\" } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$sum\" : \"$field\" } "))); } @Test public void testTestSum() { Expr e = sum(field("fld"), intExpr(12), doubleExpr(12.2)); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$sum\" : [ \"$fld\", 12, 12.2] } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$sum\" : [ \"$fld\", 12, 12.2] } "))); } @Test public void testLet() { Expr e = let(UtilsMap.of("var1", Expr.field("testField")), first(field("var1"))); log.info(Utils.toJsonString(e.toQueryObject())); - assert(Utils.toJsonString(e.toQueryObject()).equals("{ \"$let\" : { \"vars\" : { \"var1\" : \"$testField\" } , \"in\" : { \"$first\" : \"$var1\" } } } ")); + assertTrue((Utils.toJsonString(e.toQueryObject()).equals("{ \"$let\" : { \"vars\" : { \"var1\" : \"$testField\" } , \"in\" : { \"$first\" : \"$var1\" } } } "))); } @Test @@ -1021,55 +1016,55 @@ public void testLetEvaluation() { Object result = e.evaluate(UtilsMap.of("testField", 100)); assertNotNull(result); ; - assert(result.equals(100.0)); + assertTrue((result.equals(100.0))); } @Test public void testIsoDateFromParts() { Expr e = isoDateFromParts(intExpr(2020)); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2020); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2020)); e = isoDateFromParts(intExpr(2020), intExpr(2)); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2020); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2020)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2)); e = isoDateFromParts(intExpr(2020), intExpr(2), intExpr(48)); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2020); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(48); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2020)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(48)); e = isoDateFromParts(intExpr(2020), intExpr(2), intExpr(48), intExpr(23)); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2020); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(48); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(23); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2020)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(48)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(23)); e = isoDateFromParts(intExpr(2020), intExpr(2), intExpr(48), intExpr(23), intExpr(59)); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2020); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(48); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(23); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(59); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2020)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(48)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(23)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(59)); e = isoDateFromParts(intExpr(2020), intExpr(2), intExpr(48), intExpr(23), intExpr(59), intExpr(38)); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2020); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(48); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(23); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(59); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(38); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2020)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(48)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(23)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(59)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(38)); e = isoDateFromParts(intExpr(2020), intExpr(2), intExpr(48), intExpr(23), intExpr(59), intExpr(38), intExpr(999)); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2020); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(48); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(23); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(59); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(38); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(999); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2020)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(48)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(23)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(59)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(38)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(999)); e = isoDateFromParts(intExpr(2020), intExpr(2), intExpr(48), intExpr(23), intExpr(59), intExpr(38), intExpr(999), string("UTC")); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2020); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(48); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(23); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(59); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(38); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(999); - assert((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue("UTC"); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2020)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(2)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(48)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(23)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(59)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(38)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue(999)); + assertTrue(((Map)(((Map) e.toQueryObject()).get("$dateFromParts"))).containsValue("UTC")); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AggregationExpressionTests.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AggregationExpressionTests.java index 14e318264..4c8815a73 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AggregationExpressionTests.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AggregationExpressionTests.java @@ -26,23 +26,23 @@ public void test() { Object o = e.toQueryObject(); String val = Utils.toJsonString(o); log.info(val); - assert (val.equals("{ \"$add\" : [ \"$the_field\", { \"$abs\" : \"$test\" } , 128.0] } ")); + assertTrue((val.equals("{ \"$add\" : [ \"$the_field\", { \"$abs\" : \"$test\" } , 128.0] } "))); e = Expr.in(Expr.doubleExpr(1.2), Expr.arrayExpr(Expr.intExpr(12), Expr.doubleExpr(1.2), Expr.field("testfield"))); val = Utils.toJsonString(e.toQueryObject()); log.info(val); - assert (val.equals("{ \"$in\" : [ 1.2, [ 12, 1.2, \"$testfield\"]] } ")); + assertTrue((val.equals("{ \"$in\" : [ 1.2, [ 12, 1.2, \"$testfield\"]] } "))); e = Expr.zip(Arrays.asList(Expr.arrayExpr(Expr.intExpr(1), Expr.intExpr(14)), Expr.arrayExpr(Expr.intExpr(1), Expr.intExpr(14))), Expr.bool(true), Expr.field("test")); val = Utils.toJsonString(e.toQueryObject()); log.info(val); - assert (val.equals("{ \"$zip\" : { \"inputs\" : [ [ 1, 14], [ 1, 14]], \"useLongestLength\" : true, \"defaults\" : \"$test\" } } ")); + assertTrue((val.equals("{ \"$zip\" : { \"inputs\" : [ [ 1, 14], [ 1, 14]], \"useLongestLength\" : true, \"defaults\" : \"$test\" } } "))); e = Expr.filter(Expr.arrayExpr(Expr.intExpr(1), Expr.intExpr(14), Expr.string("asV")), "str", Expr.string("NEN")); val = Utils.toJsonString(e.toQueryObject()); log.info(val); - assert (val.equals("{ \"$filter\" : { \"input\" : [ 1, 14, \"asV\"], \"as\" : \"str\", \"cond\" : \"NEN\" } } ")); + assertTrue((val.equals("{ \"$filter\" : { \"input\" : [ 1, 14, \"asV\"], \"as\" : \"str\", \"cond\" : \"NEN\" } } "))); } @Test diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AggregationIteratorTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AggregationIteratorTest.java index 61d04f18b..d4ed710ec 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AggregationIteratorTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AggregationIteratorTest.java @@ -59,7 +59,7 @@ public void aggregatorIteratorTest(Morphium morphium) throws Exception { for (AggRes m : agg2.aggregateIterable()) { log.info(m.toString()); - assert (m.number != null && m.number.intValue() > 0); + assertTrue((m.number != null && m.number.intValue() > 0)); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AliasesTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AliasesTest.java index bb117d09e..170a5d2e1 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AliasesTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AliasesTest.java @@ -20,6 +20,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -36,7 +37,7 @@ public void aliasTest(Morphium morphium) throws Exception { try (morphium) { Query q = morphium.createQueryFor(ComplexObject.class).f("last_changed").eq(new Date()); assertNotNull(q, "Null Query?!?!?"); - assert(q.toQueryObject().toString().startsWith("{changed=")) : "Wrong query: " + q.toQueryObject().toString(); + assertTrue((q.toQueryObject().toString().startsWith("{changed=")), () -> String.valueOf("Wrong query: " + q.toQueryObject().toString())); log.info("All ok"); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AnnotationAndReflectionHelperTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AnnotationAndReflectionHelperTest.java index f910761fb..5ecd9c4ac 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AnnotationAndReflectionHelperTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AnnotationAndReflectionHelperTest.java @@ -98,7 +98,7 @@ public void testConvertCamelCase() { @Test public void convertCamelCaseTest() { String n = helper.convertCamelCase("thisIsATestTT"); - assert (n.equals("this_is_a_test_t_t")); + assertTrue((n.equals("this_is_a_test_t_t"))); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ArrayTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ArrayTest.java index c40842d1b..c6a81b28d 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ArrayTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ArrayTest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -71,8 +72,8 @@ public void testArrays(Morphium morphium) throws Exception { Query q = morphium.createQueryFor(ArrayTestObj.class); q.setReadPreferenceLevel(ReadPreferenceLevel.PRIMARY); obj = q.get(); - assert (obj.getIntArr() != null && obj.getIntArr().length != 0) : "No ints found"; - assert (obj.getStringArr() != null && obj.getStringArr().length > 0) : "No strings found"; + assertTrue((obj.getIntArr() != null && obj.getIntArr().length != 0), "No ints found"); + assertTrue((obj.getStringArr() != null && obj.getStringArr().length > 0), "No strings found"); } } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AsyncOperationTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AsyncOperationTest.java index 1c0543f12..d3db7586f 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AsyncOperationTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AsyncOperationTest.java @@ -62,12 +62,12 @@ public void onOperationSucceeded(AsyncOperationType type, Query> q, long duration, String error, Throwable t, Query entity, Object... param) { - assert false; + assertTrue(false); } }); TestUtils.waitForConditionToBecomeTrue(30000, "Async delete callback not called", () -> asyncCall); - assert(asyncCall); + assertTrue((asyncCall)); asyncCall = false; uc = uc.q(); uc.f(UncachedObject.Fields.counter).mod(3, 2); @@ -87,8 +87,8 @@ public void onOperationError(AsyncOperationType type, Query q, l TestUtils.waitForConditionToBecomeTrue(10000, "Update operation not persisted", () -> morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.counter).eq(0).countAll() > 0); long counter = morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.counter).eq(0).countAll(); -assert counter > 0 : "Counter is: " + counter; - assert(asyncCall); +assertTrue(counter > 0, () -> String.valueOf("Counter is: " + counter)); + assertTrue((asyncCall)); } } @@ -107,11 +107,11 @@ public void onOperationSucceeded(AsyncOperationType type, Query asyncCall = true; log.info("got read answer"); assertNotNull(result, "Error"); - assert(result.size() == 100) : "Error"; + assertTrue((result.size() == 100), "Error"); } @Override public void onOperationError(AsyncOperationType type, Query q, long duration, String error, Throwable t, UncachedObject entity, Object... param) { - assert false; + assertTrue(false); } }); waitForAsyncOperationsToStart(morphium, 3000); @@ -124,7 +124,7 @@ public void onOperationError(AsyncOperationType type, Query q, l return true; }); - assert(asyncCall); + assertTrue((asyncCall)); } } @@ -143,14 +143,14 @@ public void onOperationSucceeded(AsyncOperationType type, Query log.info("got async callback!"); assertTrue(param != null && param[0] != null); ; - assert(param[0].equals((long) 100)); + assertTrue((param[0].equals((long) 100))); } @Override public void onOperationError(AsyncOperationType type, Query q, long duration, String error, Throwable t, UncachedObject entity, Object... param) { //To change body of implemented methods use File | Settings | File Templates. log.error("got async error callback", t); //noinspection ConstantConditions - assert(false); + assertTrue((false)); } }); //waiting for thread to become active @@ -158,7 +158,7 @@ public void onOperationError(AsyncOperationType type, Query q, l TestUtils.waitForConditionToBecomeTrue(15000, "Pending async count requests not completing", () -> q.getNumberOfPendingRequests() == 0); - assert(asyncCall); + assertTrue((asyncCall)); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AutoVariableTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AutoVariableTest.java index 5ef211fec..90b2f4d48 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AutoVariableTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/AutoVariableTest.java @@ -17,6 +17,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Created with IntelliJ IDEA. @@ -40,22 +41,22 @@ public void run() { CTimeTest ct = new CTimeTest(); ct.value = "should not work"; morphium.store(ct); - assert(ct.created == null); - assert(ct.timestamp == 0); + assertTrue((ct.created == null)); + assertTrue((ct.timestamp == 0)); morphium.reread(ct); - assert(ct.created == null); - assert(ct.timestamp == 0); + assertTrue((ct.created == null)); + assertTrue((ct.timestamp == 0)); LCTest lc = new LCTest(); lc.value = "a test"; morphium.store(lc); - assert(lc.lastChange == 0); - assert(lc.lastChangeDate == null); - assert(lc.lastChangeString == null); + assertTrue((lc.lastChange == 0)); + assertTrue((lc.lastChangeDate == null)); + assertTrue((lc.lastChangeString == null)); lc.value = "updated"; morphium.store(lc); - assert(lc.lastChange == 0); - assert(lc.lastChangeDate == null); - assert(lc.lastChangeString == null); + assertTrue((lc.lastChange == 0)); + assertTrue((lc.lastChangeDate == null)); + assertTrue((lc.lastChangeString == null)); morphium.setInEntity(lc, "value", "set", false, null); TestUtils.waitForConditionToBecomeTrue(5000, "SetInEntity not persisted", @@ -69,10 +70,10 @@ public void run() { }); morphium.reread(lc); - assert(lc.lastChange == 0); - assert(lc.lastChangeDate == null); - assert(lc.lastChangeString == null); - assert(lc.value.equals("set")); + assertTrue((lc.lastChange == 0)); + assertTrue((lc.lastChangeDate == null)); + assertTrue((lc.lastChangeString == null)); + assertTrue((lc.value.equals("set"))); morphium.createQueryFor(LCTest.class).f("_id").eq(lc.morphiumId).set("value", "set"); TestUtils.waitForConditionToBecomeTrue(5000, "Query set not persisted", @@ -86,9 +87,9 @@ public void run() { }); morphium.reread(lc); - assert(lc.lastChange == 0); - assert(lc.lastChangeDate == null); - assert(lc.lastChangeString == null); + assertTrue((lc.lastChange == 0)); + assertTrue((lc.lastChangeDate == null)); + assertTrue((lc.lastChangeString == null)); LATest la = new LATest(); la.value = "last access"; morphium.store(la); @@ -98,7 +99,7 @@ public void run() { () -> morphium.findById(LATest.class, laId) != null); la = morphium.findById(LATest.class, la.morphiumId); - assert(la.lastAccess == 0); + assertTrue((la.lastAccess == 0)); } catch (Throwable ex) { threadError[0] = ex; } @@ -112,17 +113,17 @@ public void run() { () -> morphium.findById(CTimeTest.class, ct.morphiumId) != null); assertNotNull(ct.created); ; - assert(ct.timestamp != 0); + assertTrue((ct.timestamp != 0)); morphium.reread(ct); assertNotNull(ct.created); ; - assert(ct.timestamp != 0); + assertTrue((ct.timestamp != 0)); LCTest lc = new LCTest(); lc.value = "a test"; morphium.store(lc); TestUtils.waitForConditionToBecomeTrue(5000, "LCTest not persisted", () -> morphium.findById(LCTest.class, lc.morphiumId) != null); - assert(lc.lastChange != 0); + assertTrue((lc.lastChange != 0)); assertNotNull(lc.lastChangeDate); ; assertNotNull(lc.lastChangeString); @@ -134,7 +135,7 @@ public void run() { var obj = morphium.findById(LCTest.class, lc.morphiumId); return obj != null && "updated".equals(obj.value); }); - assert(lc.lastChange != 0); + assertTrue((lc.lastChange != 0)); assertNotNull(lc.lastChangeDate); ; assertNotNull(lc.lastChangeString); @@ -146,12 +147,12 @@ public void run() { return obj != null && "set".equals(obj.value); }); morphium.reread(lc); - assert(lc.lastChange != 0); + assertTrue((lc.lastChange != 0)); assertNotNull(lc.lastChangeDate); ; assertNotNull(lc.lastChangeString); ; - assert(lc.value.equals("set")); + assertTrue((lc.value.equals("set"))); morphium.createQueryFor(LCTest.class).f("_id").eq(lc.morphiumId).set("value", "set"); TestUtils.waitForConditionToBecomeTrue(5000, "Query set not persisted", () -> { @@ -159,7 +160,7 @@ public void run() { return obj != null && "set".equals(obj.value); }); morphium.reread(lc); - assert(lc.lastChange != 0); + assertTrue((lc.lastChange != 0)); assertNotNull(lc.lastChangeDate); ; assertNotNull(lc.lastChangeString); @@ -172,8 +173,8 @@ public void run() { () -> morphium.findById(LATest.class, laId) != null); long stored = System.currentTimeMillis(); la = morphium.findById(LATest.class, la.morphiumId); - assert(la.lastAccess != 0); - assert(la.lastAccess >= stored) : "lastAccess " + la.lastAccess + " should be >= stored " + stored; + assertTrue((la.lastAccess != 0)); + assertTrue((la.lastAccess >= stored), String.valueOf("lastAccess " + la.lastAccess + " should be >= stored " + stored)); while (t.isAlive()) { Thread.yield(); @@ -191,33 +192,33 @@ public void disableAutoValues(Morphium morphium) throws Exception { CTimeTest ct = new CTimeTest(); ct.value = "should not work"; morphium.store(ct); - assert(ct.created == null); - assert(ct.timestamp == 0); + assertTrue((ct.created == null)); + assertTrue((ct.timestamp == 0)); morphium.reread(ct); - assert(ct.created == null); - assert(ct.timestamp == 0); + assertTrue((ct.created == null)); + assertTrue((ct.timestamp == 0)); LCTest lc = new LCTest(); lc.value = "a test"; morphium.store(lc); - assert(lc.lastChange == 0); - assert(lc.lastChangeDate == null); - assert(lc.lastChangeString == null); + assertTrue((lc.lastChange == 0)); + assertTrue((lc.lastChangeDate == null)); + assertTrue((lc.lastChangeString == null)); lc.value = "updated"; morphium.store(lc); - assert(lc.lastChange == 0); - assert(lc.lastChangeDate == null); - assert(lc.lastChangeString == null); + assertTrue((lc.lastChange == 0)); + assertTrue((lc.lastChangeDate == null)); + assertTrue((lc.lastChangeString == null)); morphium.setInEntity(lc, "value", "set", false, null); morphium.reread(lc); - assert(lc.lastChange == 0); - assert(lc.lastChangeDate == null); - assert(lc.lastChangeString == null); - assert(lc.value.equals("set")); + assertTrue((lc.lastChange == 0)); + assertTrue((lc.lastChangeDate == null)); + assertTrue((lc.lastChangeString == null)); + assertTrue((lc.value.equals("set"))); morphium.createQueryFor(LCTest.class).f("_id").eq(lc.morphiumId).set("value", "set"); morphium.reread(lc); - assert(lc.lastChange == 0); - assert(lc.lastChangeDate == null); - assert(lc.lastChangeString == null); + assertTrue((lc.lastChange == 0)); + assertTrue((lc.lastChangeDate == null)); + assertTrue((lc.lastChangeString == null)); LATest la = new LATest(); la.value = "last access"; morphium.store(la); @@ -225,7 +226,7 @@ public void disableAutoValues(Morphium morphium) throws Exception { TestUtils.waitForConditionToBecomeTrue(5000, "LATest not persisted", () -> morphium.findById(LATest.class, laId2) != null); la = morphium.findById(LATest.class, la.morphiumId); - assert(la.lastAccess == 0); + assertTrue((la.lastAccess == 0)); } finally { morphium.getConfig().objectMappingSettings().enableAutoValues(); } @@ -242,26 +243,26 @@ public void testCreationTime(Morphium morphium) throws Exception { TestUtils.waitForConditionToBecomeTrue(5000, "CTimeTest not persisted", () -> morphium.createQueryFor(CTimeTest.class).countAll() == 1); assertNotNull(ct.created); - assert(ct.timestamp != 0); + assertTrue((ct.timestamp != 0)); Query q = morphium.createQueryFor(CTimeTest.class).f("value").eq("annother test"); q.set("additional", "value", true, true, null); TestUtils.waitForConditionToBecomeTrue(5000, "Query upsert not persisted", () -> morphium.createQueryFor(CTimeTest.class).f("value").eq("annother test").countAll() == 1); - assert(q.countAll() == 1) : "Count wrong: " + q.countAll(); - assert(q.get().timestamp != 0); + assertTrue((q.countAll() == 1), String.valueOf("Count wrong: " + q.countAll())); + assertTrue((q.get().timestamp != 0)); assertNotNull(q.get().created); ; - assert(q.get().value.equals("annother test")); + assertTrue((q.get().value.equals("annother test"))); q = morphium.createQueryFor(CTimeTest.class).f("value").eq("additional test"); morphium.push(q, "lst", "value", true, true); TestUtils.waitForConditionToBecomeTrue(5000, "Push upsert not persisted", () -> morphium.createQueryFor(CTimeTest.class).f("value").eq("additional test").countAll() == 1); - assert(q.countAll() == 1) : "Count wrong: " + q.countAll(); - assert(q.get().timestamp != 0); + assertTrue((q.countAll() == 1), String.valueOf("Count wrong: " + q.countAll())); + assertTrue((q.get().timestamp != 0)); assertNotNull(q.get().created); ; - assert(q.get().value.equals("additional test")); - assert(q.get().lst.size() == 1); + assertTrue((q.get().value.equals("additional test"))); + assertTrue((q.get().lst.size() == 1)); List lst = new ArrayList<>(); for (int i = 0; i < 100; i++) { @@ -274,7 +275,7 @@ public void testCreationTime(Morphium morphium) throws Exception { morphium.storeList(lst); for (CTimeTest tst : q.q().asIterable()) { - assert(tst.timestamp != 0); + assertTrue((tst.timestamp != 0)); assertNotNull(tst.created); ; assertNotNull(tst.createdString); @@ -295,7 +296,7 @@ public void testLastAccess(Morphium morphium) throws Exception { TestUtils.waitForConditionToBecomeTrue(5000, "LATest objects not persisted", () -> morphium.createQueryFor(LATest.class).countAll() == 2); la = morphium.createQueryFor(LATest.class).f("value").eq("value1").get(); - assert(la.lastAccess != 0); + assertTrue((la.lastAccess != 0)); assertNotNull(la.lastAccessDate); long lastAcc = la.lastAccess; // Wait for lastAccess to change - timestamps may be in same millisecond on fast systems @@ -359,9 +360,9 @@ public void testLastChange(Morphium morphium) throws Exception { var obj = morphium.createQueryFor(LCTest.class).f("value").eq("different").get(); return obj != null; }); - assert(lc.lastChange != 0); + assertTrue((lc.lastChange != 0)); assertNotNull(lc.lastChangeDate); - assert(lc.lastChange >= created) : "lastChange " + lc.lastChange + " should be >= created " + created; + assertTrue((lc.lastChange >= created), String.valueOf("lastChange " + lc.lastChange + " should be >= created " + created)); Query q = morphium.createQueryFor(LCTest.class); q.set("value", "all_same", false, true); long cmp = 0; @@ -371,8 +372,8 @@ public void testLastChange(Morphium morphium) throws Exception { cmp = tst.lastChange; } - assert(tst.lastChange != 0); - assert(tst.lastChange == cmp) : "Last change wrong cmp: " + cmp + " but is: " + tst.lastChange; + assertTrue((tst.lastChange != 0)); + assertTrue((tst.lastChange == cmp), String.valueOf("Last change wrong cmp: " + cmp + " but is: " + tst.lastChange)); assertNotNull(tst.lastChangeDate); ; assertNotNull(tst.lastChangeString); @@ -412,7 +413,7 @@ record = new CTimeTestStringId(); record = q.get(); assertNotNull(record.created); ; - assert(record.timestamp != 0); + assertTrue((record.timestamp != 0)); long created = record.timestamp; record.value = "v1*"; morphium.store(record); @@ -420,13 +421,13 @@ record = q.get(); () -> morphium.createQueryFor(CTimeTestStringId.class).f("value").eq("v1*").get() != null); record = q.q().f("value").eq("v1*").get(); assertNotNull(record); - assert(record.timestamp == created) : "Record timestamp " + record.timestamp; + assertTrue((record.timestamp == created), String.valueOf("Record timestamp " + record.timestamp)); q = q.q().f("value").eq("new"); q.set("additional", "1111", true, true); TestUtils.waitForConditionToBecomeTrue(5000, "Query upsert not persisted", () -> morphium.createQueryFor(CTimeTestStringId.class).f("value").eq("new").get() != null); record = q.get(); - assert(record.timestamp != 0); + assertTrue((record.timestamp != 0)); ArrayList lst = new ArrayList<>(); for (int i = 0; i < 100; i++) { @@ -442,7 +443,7 @@ record = q.get(); () -> morphium.createQueryFor(CTimeTestStringId.class).countAll() >= 100); for (CTimeTestStringId ct : q.q().asIterable()) { - assert(ct.timestamp != 0); + assertTrue((ct.timestamp != 0)); assertNotNull(ct.created); ; } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/BasicAdminTests.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/BasicAdminTests.java index c1417e8a4..3c85682da 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/BasicAdminTests.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/BasicAdminTests.java @@ -47,7 +47,7 @@ public class BasicAdminTests extends MultiDriverTestBase { @MethodSource("getMorphiumInstancesNoSingle") public void readPreferenceTest(Morphium morphium) { ReadPreferenceLevel.NEAREST.setPref(ReadPreference.nearest()); - assert(ReadPreferenceLevel.NEAREST.getPref().getType().equals(ReadPreference.nearest().getType())); + assertTrue((ReadPreferenceLevel.NEAREST.getPref().getType().equals(ReadPreference.nearest().getType()))); } @@ -61,7 +61,7 @@ public void getDatabaseListTest(Morphium morphium) { morphium.save(new UncachedObject("str", 1)); List dbs = morphium.listDatabases(); assertNotNull(dbs); - assert(dbs.size() != 0); + assertTrue((dbs.size() != 0)); for (String s : dbs) { log.info("Got DB: " + s); @@ -164,7 +164,7 @@ public void existsTest(Morphium morphium) throws Exception { while (TestUtils.countUC(morphium) < 10) { Thread.sleep(100); - assert(System.currentTimeMillis() - s < morphium.getConfig().connectionSettings().getMaxWaitTime()); + assertTrue((System.currentTimeMillis() - s < morphium.getConfig().connectionSettings().getMaxWaitTime())); } Query q = morphium.createQueryFor(UncachedObject.class); @@ -175,20 +175,20 @@ public void existsTest(Morphium morphium) throws Exception { while (c != 1) { c = q.countAll(); Thread.sleep(100); - assert(System.currentTimeMillis() - s < morphium.getConfig().connectionSettings().getMaxWaitTime()); + assertTrue((System.currentTimeMillis() - s < morphium.getConfig().connectionSettings().getMaxWaitTime())); } - assert(c == 1) : "Count wrong: " + c; + assertTrue((c == 1), String.valueOf("Count wrong: " + c)); UncachedObject o = q.get(); s = System.currentTimeMillis(); while (o == null) { Thread.sleep(100); - assert(System.currentTimeMillis() - s < morphium.getConfig().connectionSettings().getMaxWaitTime()); + assertTrue((System.currentTimeMillis() - s < morphium.getConfig().connectionSettings().getMaxWaitTime())); o = q.get(); } - assert(o.getCounter() == 1); + assertTrue((o.getCounter() == 1)); } } @ParameterizedTest diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/BufferedWriterTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/BufferedWriterTest.java index 1f7c2ed67..13076b0cd 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/BufferedWriterTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/BufferedWriterTest.java @@ -85,7 +85,7 @@ public void testWriteBufferUpdate(Morphium morphium) throws Exception { TestUtils.waitForConditionToBecomeTrue(5000, "Update operations not persisted", () -> morphium.createQueryFor(BufferedBySizeObject.class).countAll() == 3); q = morphium.createQueryFor(BufferedBySizeObject.class); - assert(q.countAll() == 3); + assertTrue((q.countAll() == 3)); for (BufferedBySizeObject o : q.asList()) { log.info("Counter: " + o.getCounter()); @@ -121,18 +121,18 @@ public void testWriteBufferUpdateMap(Morphium morphium) throws Exception { TestUtils.waitForWrites(morphium, log); TestUtils.waitForConditionToBecomeTrue(5000, "Expected 100 BufferedByTimeObject documents", () -> morphium.createQueryFor(BufferedByTimeObject.class).countAll() == 100); - assert(morphium.createQueryFor(BufferedByTimeObject.class).f(UncachedObject.Fields.counter).eq(101).countAll() == 100); - assert(morphium.createQueryFor(BufferedByTimeObject.class).f(UncachedObject.Fields.dval).eq(1.1).countAll() == 100); + assertTrue((morphium.createQueryFor(BufferedByTimeObject.class).f(UncachedObject.Fields.counter).eq(101).countAll() == 100)); + assertTrue((morphium.createQueryFor(BufferedByTimeObject.class).f(UncachedObject.Fields.dval).eq(1.1).countAll() == 100)); q = morphium.createQueryFor(BufferedByTimeObject.class).f("counter").eq(201); morphium.inc(q, toInc, true, false, null); waitForAsyncOperationsToStart(morphium, 1000); TestUtils.waitForWrites(morphium, log); TestUtils.waitForConditionToBecomeTrue(5000, "Expected 101 BufferedByTimeObject documents", () -> morphium.createQueryFor(BufferedByTimeObject.class).countAll() == 101); - assert(morphium.createQueryFor(BufferedByTimeObject.class).f(UncachedObject.Fields.counter).eq(101).countAll() == 100); - assert(morphium.createQueryFor(BufferedByTimeObject.class).f(UncachedObject.Fields.counter).eq(202).countAll() == 1); - assert(morphium.createQueryFor(BufferedByTimeObject.class).f(UncachedObject.Fields.dval).eq(0.1).countAll() == 1); - assert(morphium.createQueryFor(BufferedByTimeObject.class).f(UncachedObject.Fields.dval).eq(1.1).countAll() == 100); + assertTrue((morphium.createQueryFor(BufferedByTimeObject.class).f(UncachedObject.Fields.counter).eq(101).countAll() == 100)); + assertTrue((morphium.createQueryFor(BufferedByTimeObject.class).f(UncachedObject.Fields.counter).eq(202).countAll() == 1)); + assertTrue((morphium.createQueryFor(BufferedByTimeObject.class).f(UncachedObject.Fields.dval).eq(0.1).countAll() == 1)); + assertTrue((morphium.createQueryFor(BufferedByTimeObject.class).f(UncachedObject.Fields.dval).eq(1.1).countAll() == 100)); } @ParameterizedTest @@ -145,13 +145,13 @@ public void testWriteBufferIncs(Morphium morphium) throws Exception { BufferedMorphiumWriterImpl wr = (BufferedMorphiumWriterImpl) morphium.getWriterForClass(BufferedBySizeObject.class); Query q = morphium.createQueryFor(BufferedBySizeObject.class).f(UncachedObject.Fields.counter).eq(100); morphium.inc(q, "dval", 1, true, false); - assert(wr.writeBufferCount() >= 1); + assertTrue((wr.writeBufferCount() >= 1)); q = morphium.createQueryFor(BufferedBySizeObject.class).f(UncachedObject.Fields.counter).eq(100); morphium.inc(q, "dval", 1.0, true, false); - assert(wr.writeBufferCount() >= 1); + assertTrue((wr.writeBufferCount() >= 1)); q = morphium.createQueryFor(BufferedBySizeObject.class).f(UncachedObject.Fields.counter).eq(100); morphium.dec(q, "dval", 1.0, true, false); - assert(wr.writeBufferCount() >= 1); + assertTrue((wr.writeBufferCount() >= 1)); TestUtils.waitForConditionToBecomeTrue(10000, "Write buffer not flushing", () -> { @@ -164,11 +164,11 @@ public void testWriteBufferIncs(Morphium morphium) throws Exception { TestUtils.waitForConditionToBecomeTrue(10000, "Inc operations not persisted", () -> morphium.createQueryFor(BufferedBySizeObject.class).countAll() == 1); q = morphium.createQueryFor(BufferedBySizeObject.class); - assert(q.countAll() == 1) : "Counted " + q.countAll(); + assertTrue((q.countAll() == 1), String.valueOf("Counted " + q.countAll())); BufferedBySizeObject o = q.get(); log.info("Counter: " + o.getCounter()); - assert(o.getCounter() == 100); - assert(o.getDval() == 1.0); + assertTrue((o.getCounter() == 100)); + assertTrue((o.getDval() == 1.0)); } @ParameterizedTest @@ -223,7 +223,7 @@ public void testWriteBufferBySize(Morphium morphium) throws Exception { return writeBufferCount == 0 && count == 1500; }); - assert(System.currentTimeMillis() - start < 120000); + assertTrue((System.currentTimeMillis() - start < 120000)); } @ParameterizedTest @@ -252,7 +252,7 @@ public void testWriteBufferByTime(Morphium morphium) throws Exception { }); log.info("Found proper amount..."); - assert(System.currentTimeMillis() - start < 120000); + assertTrue((System.currentTimeMillis() - start < 120000)); } @ParameterizedTest @@ -301,7 +301,7 @@ public void testWriteBufferBySizeWithIngoreNewStrategy(Morphium morphium) throws TestUtils.waitForConditionToBecomeTrue(10000, "Waiting for buffer to flush", () -> morphium.getWriteBufferCount() == 0); long count = morphium.createQueryFor(BufferedBySizeIgnoreNewObject.class).countAll(); - assert(count < 1500); + assertTrue((count < 1500)); } @ParameterizedTest @@ -326,7 +326,7 @@ public void testWriteBufferBySizeWithWaitStrategy(Morphium morphium) throws Exce TestUtils.waitForConditionToBecomeTrue(10000, "Waiting for buffer to flush", () -> morphium.getWriteBufferCount() == 0); long count = morphium.createQueryFor(BufferedBySizeIgnoreNewObject.class).countAll(); - assert(count < 1500); + assertTrue((count < 1500)); } @ParameterizedTest @@ -359,7 +359,7 @@ public void testComplexObject(Morphium morphium) throws Exception { () -> m.createQueryFor(ComplexObjectBuffered.class).countAll() == 100); ComplexObjectBuffered buf = m.createQueryFor(ComplexObjectBuffered.class).f("ein_text").eq("The text " + 0).get(); assertNotNull(buf);; - assert(m.createQueryFor(ComplexObjectBuffered.class).countAll() == 100); + assertTrue((m.createQueryFor(ComplexObjectBuffered.class).countAll() == 100)); } @ParameterizedTest @@ -458,7 +458,34 @@ public void testNonObjectIdID(Morphium morphium) throws Exception { TestUtils.waitForConditionToBecomeTrue(10000, "Write buffer not flushing on second batch", () -> m.getWriteBufferCount() == 0); - assert(m.createQueryFor(SimpleObject.class).countAll() == 100); + assertTrue((m.createQueryFor(SimpleObject.class).countAll() == 100)); + } + + @ParameterizedTest + @MethodSource("getMorphiumInstancesNoSingle") + public void testWriteBufferRemoveByQuery(Morphium morphium) throws Exception { + for (int i = 0; i < 100; i++) { + SimpleObject o = new SimpleObject(); + o.setMyId("id_" + i); + o.setCount(i); + o.setValue("v" + i); + morphium.store(o); + } + + TestUtils.waitForWrites(morphium, log); + TestUtils.waitForConditionToBecomeTrue(10000, "objects not stored", + () -> morphium.createQueryFor(SimpleObject.class).countAll() == 100); + + // remove-by-query on a write-buffered entity has to delete ALL matches, not just one + morphium.remove(morphium.createQueryFor(SimpleObject.class).f(SimpleObject.Fields.count).lt(50)); + TestUtils.waitForWrites(morphium, log); + TestUtils.waitForConditionToBecomeTrue(10000, "buffered remove did not delete all matches", + () -> morphium.createQueryFor(SimpleObject.class).countAll() == 50); + + morphium.clearCollection(SimpleObject.class); + TestUtils.waitForWrites(morphium, log); + TestUtils.waitForConditionToBecomeTrue(10000, "clearCollection did not empty the collection", + () -> morphium.createQueryFor(SimpleObject.class).countAll() == 0); } @WriteBuffer(size = 100, timeout = 1000) diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/BulkInsertTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/BulkInsertTest.java index 99c14fd58..19d746513 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/BulkInsertTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/BulkInsertTest.java @@ -47,9 +47,8 @@ public void maxWriteBatchTest(Morphium morphium) throws Exception { lst.add(u); } morphium.storeList(lst); - Thread.sleep(1000); - long l = TestUtils.countUC(morphium); - assert (l == 4212) : "Count wrong: " + l; + TestUtils.waitForConditionToBecomeTrue(5000, "Count wrong", + () -> TestUtils.countUC(morphium) == 4212); for (UncachedObject u : lst) { u.setCounter(u.getCounter() + 1000); @@ -103,7 +102,7 @@ public void bulkInsert(Morphium morphium) throws Exception { log.info("storing objects one by one took " + dur + " ms"); Query q = morphium.createQueryFor(UncachedObject.class); q.setReadPreferenceLevel(ReadPreferenceLevel.PRIMARY); - assert (q.countAll() == 100) : "Assert not all stored yet????"; + TestUtils.waitForConditionToBecomeTrue(5000, "Not all stored yet", () -> q.countAll() == 100); } } @@ -139,8 +138,8 @@ public void onOperationError(AsyncOperationType type, Query q, l TestUtils.waitForWrites(morphium, log); long dur = System.currentTimeMillis() - start; log.info("storing objects one by one async took " + dur + " ms"); - Thread.sleep(500); - assertEquals(100, TestUtils.countUC(morphium), "Write wrong!"); + TestUtils.waitForConditionToBecomeTrue(5000, "Write wrong!", + () -> TestUtils.countUC(morphium) == 100); assertTrue (asyncSuccess, "Async call failed"); assertTrue (asyncCall, "Async callback not called"); @@ -161,7 +160,7 @@ public void onOperationError(AsyncOperationType type, Query q, l log.info("storing objects one by one took " + dur + " ms"); Query q = morphium.createQueryFor(UncachedObject.class); q.setReadPreferenceLevel(ReadPreferenceLevel.PRIMARY); - assertEquals (1000, q.countAll(), "Not all stored yet????"); + TestUtils.waitForConditionToBecomeTrue(5000, "Not all stored yet", () -> q.countAll() == 1000); log.info("Test finished!"); } } @@ -180,11 +179,9 @@ public void bulkInsertNonId(Morphium morphium) throws Exception { } morphium.storeList(prs); - Thread.sleep(1000); assertNotNull(prs.get(0).getId()); - ; - long cnt = morphium.createQueryFor(Person.class).countAll(); - assert (cnt == 100); + TestUtils.waitForConditionToBecomeTrue(5000, "Not all persons stored", + () -> morphium.createQueryFor(Person.class).countAll() == 100); } } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/BulkOperationTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/BulkOperationTest.java index f917024eb..3337510a8 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/BulkOperationTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/BulkOperationTest.java @@ -11,6 +11,7 @@ import java.util.Arrays; import java.util.Map; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Created with IntelliJ IDEA. @@ -92,7 +93,7 @@ public void bulkTest(Morphium morphium) throws Exception { "Bulk set operation not persisted", () -> morphium.createQueryFor(UncachedObject.class).f("counter").eq(999).countAll() == 100); for (UncachedObject o : morphium.createQueryFor(UncachedObject.class).asList()) { - assert (o.getCounter() == 999) : "Counter is " + o.getCounter(); + assertTrue((o.getCounter() == 999), () -> String.valueOf("Counter is " + o.getCounter())); } } } @@ -116,7 +117,7 @@ public void incTest(Morphium morphium) throws Exception { log.error("Counter is < 1000!?"); morphium.reread(o); } - assert (o.getCounter() >= 1000) : "Counter is " + o.getCounter() + " - Total number: " + TestUtils.countUC(morphium) + " >= 1000: " + morphium.createQueryFor(UncachedObject.class).f("counter").gte(1000).countAll(); + assertTrue((o.getCounter() >= 1000), () -> String.valueOf("Counter is " + o.getCounter() + " - Total number: " + TestUtils.countUC(morphium) + " >= 1000: " + morphium.createQueryFor(UncachedObject.class).f("counter").gte(1000).countAll())); } } } @@ -154,10 +155,10 @@ public void postUpdate(Morphium m, Class cls, Enum u incTest(morphium); TestUtils.waitForConditionToBecomeTrue(3000, "Bulk operation callbacks not triggered", () -> preUpdate && postUpdate); - assert (preUpdate); - assert (postUpdate); - assert (!preRemove); - assert (!postRemove); + assertTrue((preUpdate)); + assertTrue((postUpdate)); + assertTrue((!preRemove)); + assertTrue((!postRemove)); morphium.removeListener(listener); } } @@ -196,12 +197,12 @@ public void bulkTestReturnCounts(Morphium morphium) throws Exception { log.info("Bulk operation results: " + ret); // Verify return values are present and correct -assert ret != null : "Bulk operation should return results"; - assert ret.containsKey("num_inserted") : "Result should contain num_inserted"; - assert ret.containsKey("num_matched") : "Result should contain num_matched"; - assert ret.containsKey("num_modified") : "Result should contain num_modified"; - assert ret.containsKey("num_deleted") : "Result should contain num_deleted"; - assert ret.containsKey("num_upserts") : "Result should contain num_upserts"; +assertTrue(ret != null, "Bulk operation should return results"); + assertTrue(ret.containsKey("num_inserted"), "Result should contain num_inserted"); + assertTrue(ret.containsKey("num_matched"), "Result should contain num_matched"); + assertTrue(ret.containsKey("num_modified"), "Result should contain num_modified"); + assertTrue(ret.containsKey("num_deleted"), "Result should contain num_deleted"); + assertTrue(ret.containsKey("num_upserts"), "Result should contain num_upserts"); int inserted = ((Number) ret.get("num_inserted")).intValue(); int matched = ((Number) ret.get("num_matched")).intValue(); @@ -213,15 +214,15 @@ public void bulkTestReturnCounts(Morphium morphium) throws Exception { inserted, matched, modified, deleted, upserts)); // Verify counts -assert inserted == 5 : "Should have inserted 5 documents, got: " + inserted; -assert matched >= 10 : "Should have matched at least 10 documents, got: " + matched; -assert modified >= 10 : "Should have modified at least 10 documents, got: " + modified; -assert deleted >= 10 : "Should have deleted at least 10 documents, got: " + deleted; -assert upserts == 1 : "Should have 1 upsert, got: " + upserts; +assertTrue(inserted == 5, () -> String.valueOf("Should have inserted 5 documents, got: " + inserted)); +assertTrue(matched >= 10, () -> String.valueOf("Should have matched at least 10 documents, got: " + matched)); +assertTrue(modified >= 10, () -> String.valueOf("Should have modified at least 10 documents, got: " + modified)); +assertTrue(deleted >= 10, () -> String.valueOf("Should have deleted at least 10 documents, got: " + deleted)); +assertTrue(upserts == 1, () -> String.valueOf("Should have 1 upsert, got: " + upserts)); // Check upserted IDs if (upserts > 0) { - assert ret.containsKey("upsertedIds") : "Result should contain upsertedIds when upserts occurred"; + assertTrue(ret.containsKey("upsertedIds"), "Result should contain upsertedIds when upserts occurred"); log.info("Upserted IDs: " + ret.get("upsertedIds")); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CacheFunctionalityTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CacheFunctionalityTest.java index 9bf1040b4..93b00ccb0 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CacheFunctionalityTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CacheFunctionalityTest.java @@ -20,6 +20,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * TODO: Add Documentation here @@ -85,9 +86,9 @@ public void emptyResultTest(Morphium morphium) throws Exception { log.info("Reached " + i); } CachedObject o = morphium.createQueryFor(CachedObject.class).f(CachedObject.Fields.counter).eq(amount + 1).get(); - assert (o == null); + assertTrue((o == null)); List lst = morphium.createQueryFor(CachedObject.class).f("counter").gt(amount + 1).asList(); - assert (lst == null || lst.size() == 0); + assertTrue((lst == null || lst.size() == 0)); } long dur = System.currentTimeMillis() - start; @@ -101,7 +102,7 @@ private void checkStats(Morphium morphium, long dur) { log.info("Cache hit ratio: " + morphium.getStatistics().get(StatisticKeys.CHITSPERC.name())); log.info("Cache hits : " + morphium.getStatistics().get(StatisticKeys.CHITS.name())); log.info("Cache miss : " + morphium.getStatistics().get(StatisticKeys.CMISS.name())); - assert (morphium.getStatistics().get(StatisticKeys.CHITS.name()) >= 90); + assertTrue((morphium.getStatistics().get(StatisticKeys.CHITS.name()) >= 90)); } @ParameterizedTest @@ -123,7 +124,7 @@ public void globalCacheSettingsTest(Morphium morphium) throws Exception { Cache cache = morphium.getARHelper().getAnnotationFromHierarchy(SpecCachedOjbect.class, Cache.class); log.info("Housekeeping: " + hcTime); log.info("Cache valid: " + gcTime); - assert (cache.timeout() == -1); + assertTrue((cache.timeout() == -1)); int amount = 100; for (int i = 0; i < amount; i++) { @@ -143,13 +144,13 @@ public void globalCacheSettingsTest(Morphium morphium) throws Exception { assertNotNull(morphium.createQueryFor(SpecCachedOjbect.class).f("counter").eq(i).get()); ; } - assert (morphium.getCache().getSizes().get("idCache|" + SpecCachedOjbect.class.getName()) > 0); + assertTrue((morphium.getCache().getSizes().get("idCache|" + SpecCachedOjbect.class.getName()) > 0)); TestUtils.waitForConditionToBecomeTrue(hcTime + 1000, "Cache not maintained after housekeeping", () -> morphium.getCache().getSizes().get("idCache|" + SpecCachedOjbect.class.getName()) > 0); - assert (morphium.getCache().getSizes().get("idCache|" + SpecCachedOjbect.class.getName()) > 0); + assertTrue((morphium.getCache().getSizes().get("idCache|" + SpecCachedOjbect.class.getName()) > 0)); TestUtils.waitForConditionToBecomeTrue(gcTime + 2000, "Cache not cleared after global cache time", () -> morphium.getCache().getSizes().get("idCache|" + SpecCachedOjbect.class.getName()) == 0); - assert (morphium.getCache().getSizes().get("idCache|" + SpecCachedOjbect.class.getName()) == 0) : "Stored still: " + morphium.getCache().getSizes().get("idCache|" + SpecCachedOjbect.class.getName()); + assertTrue((morphium.getCache().getSizes().get("idCache|" + SpecCachedOjbect.class.getName()) == 0), () -> String.valueOf("Stored still: " + morphium.getCache().getSizes().get("idCache|" + SpecCachedOjbect.class.getName()))); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CacheListenerTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CacheListenerTest.java index b80739b32..896c86b96 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CacheListenerTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CacheListenerTest.java @@ -10,6 +10,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Created with IntelliJ IDEA. @@ -60,7 +61,7 @@ public boolean wouldRemoveEntryFromCache(Object key, CacheEntry toRemove, }; try { morphium.getCache().addCacheListener(cl); - assert (morphium.getCache().isListenerRegistered(cl)); + assertTrue((morphium.getCache().isListenerRegistered(cl))); super.createCachedObjects(morphium, 100); @@ -70,13 +71,13 @@ public boolean wouldRemoveEntryFromCache(Object key, CacheEntry toRemove, } TestUtils.waitForWrites(morphium, log); Thread.sleep(1000); - assert (wouldAdd); + assertTrue((wouldAdd)); super.createCachedObjects(morphium, 10); TestUtils.waitForWrites(morphium, log); log.info("Waiting for would clear message"); Thread.sleep(1500); - assert (wouldClear); + assertTrue((wouldClear)); } finally { morphium.getCache().removeCacheListener(cl); diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CacheSyncTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CacheSyncTest.java index e53138aa5..9bc6c60fe 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CacheSyncTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CacheSyncTest.java @@ -62,13 +62,11 @@ public void sendClearMsgTest(Morphium morphium) throws Exception { Query q = morphium.createQueryFor(Msg.class); long cnt = q.countAll(); - assert (cnt == 0) : "Already a message?!?! " + cnt; + assertTrue((cnt == 0), () -> String.valueOf("Already a message?!?! " + cnt)); cs.sendClearMessage(CachedObject.class, "test"); - Thread.sleep(2000); TestUtils.waitForWrites(morphium, log); - cnt = q.countAll(); - assert (cnt == 1) : "there should be one msg, there are " + cnt; + TestUtils.waitForConditionToBecomeTrue(10000, "there should be one msg", () -> q.countAll() == 1); msg.terminate(); cs.detach(); while (cs.isAttached()) { @@ -99,7 +97,7 @@ public void removeFromCacheTest(Morphium morphium) throws Exception { c.asList(); } assertNotNull(morphium.getStatistics().get(StatisticKeys.CACHE_ENTRIES.name()), "Cache entries not set?"); - assert (morphium.getStatistics().get(StatisticKeys.CACHE_ENTRIES.name()) > 0) : "Cache entries not set? " + morphium.getStatistics().get(StatisticKeys.CACHE_ENTRIES.name()); + assertTrue((morphium.getStatistics().get(StatisticKeys.CACHE_ENTRIES.name()) > 0), () -> String.valueOf("Cache entries not set? " + morphium.getStatistics().get(StatisticKeys.CACHE_ENTRIES.name()))); Thread.sleep(2500); Query c = morphium.createQueryFor(CachedObject.class); c = c.f("counter").eq(10); @@ -107,7 +105,7 @@ public void removeFromCacheTest(Morphium morphium) throws Exception { Double cnt = morphium.getStatistics().get(StatisticKeys.CACHE_ENTRIES.name()); morphium.getCache().removeEntryFromCache(CachedObject.class, id); Double cnt2 = morphium.getStatistics().get(StatisticKeys.CACHE_ENTRIES.name()); - assert (morphium.getStatistics().get(StatisticKeys.CACHE_ENTRIES.name()) <= cnt - 1) : "Cache entries not set?"; + assertTrue((morphium.getStatistics().get(StatisticKeys.CACHE_ENTRIES.name()) <= cnt - 1), "Cache entries not set?"); log.info("Count 1: " + cnt + " ---> " + cnt2); } @@ -145,10 +143,8 @@ public void clearCacheTest(Morphium morphium) throws Exception { System.out.println("Stats " + morphium.getStatistics().toString()); assertNotNull(morphium.getStatistics().get(StatisticKeys.CACHE_ENTRIES.name()), "Cache entries not set?"); cs1.sendClearAllMessage("test"); - Thread.sleep(5500); - if ((morphium.getStatistics().get(StatisticKeys.CACHE_ENTRIES.name()) != 0)) { - throw new AssertionError("Cache entries set? Entries: " + morphium.getStatistics().get(StatisticKeys.CACHE_ENTRIES.name())); - } + TestUtils.waitForConditionToBecomeTrue(10000, "Cache entries still set", + () -> morphium.getStatistics().get(StatisticKeys.CACHE_ENTRIES.name()) == 0); msg1.terminate(); msg2.terminate(); cs1.detach(); @@ -190,7 +186,7 @@ public void idCacheTest(Morphium morphium) throws Exception { morphium.store(o); } TestUtils.waitForWrites(morphium, log); - Thread.sleep(5000); + TestUtils.waitForConditionToBecomeTrue(30000, "objects not stored yet", () -> morphium.createQueryFor(IdCachedObject.class).countAll() == 100); var qu = morphium.createQueryFor(IdCachedObject.class); var e = qu.q().sort(IdCachedObject.Fields.counter).get(); log.info("First: " + e.getCounter()); @@ -212,6 +208,12 @@ public void idCacheTest(Morphium morphium) throws Exception { morphium.clearCollection(IdCachedObject.class); + // The clear is asynchronous for this cached entity - without waiting for it to become + // visible it races the 100 stores below and wipes some of them, so the ==100 wait at the + // end of this block can never come true (seen on all four CI server phases 2026-08-13; + // the pre-hardening sleep never asserted the count and silently tolerated the loss). + TestUtils.waitForConditionToBecomeTrue(15000, "collection not cleared", + () -> morphium.createQueryFor(IdCachedObject.class).countAll() == 0); MorphiumMessaging idMsg1 = morphium.createMessaging(); idMsg1.setPause(100).setMultithreadded(true); idMsg1.start(); @@ -228,7 +230,7 @@ public void idCacheTest(Morphium morphium) throws Exception { dur = System.currentTimeMillis() - start; log.info("Storing with synchronizer: " + dur + " ms"); - Thread.sleep(15000); + TestUtils.waitForConditionToBecomeTrue(30000, "objects not stored with synchronizer", () -> morphium.createQueryFor(IdCachedObject.class).countAll() == 100); start = System.currentTimeMillis(); int notFoundCounter = 0; for (int i = 0; i < 100; i++) { @@ -246,7 +248,7 @@ public void idCacheTest(Morphium morphium) throws Exception { } else { obj.setCounter(i + 2000); } - assert (notFoundCounter < 10) : "too many objects not found"; + assertTrue((notFoundCounter < 10), "too many objects not found"); morphium.store(obj); } dur = System.currentTimeMillis() - start; @@ -344,27 +346,25 @@ public void postSendClearMsg(Class cls, Msg m) { morphium.store(new CachedObject()); TestUtils.waitForWrites(morphium, log); try { - Thread.sleep(4500); - } catch (InterruptedException e) { - throw new RuntimeException(e); + TestUtils.waitForConditionToBecomeTrue(10000, "cache sync listeners not all triggered", + () -> preSendClear && postSendClear && preClear && postclear); + } finally { + cs1.detach(); + cs2.detach(); + msg1.terminate(); + msg2.terminate(); } - cs1.detach(); - cs2.detach(); - msg1.terminate(); - msg2.terminate(); - }).start(); while (cs1.isAttached()) { log.info("still attached - waiting"); Thread.sleep(500); } - Thread.sleep(5000); - assert (preClear); - assert (postclear); - assert (preSendClear); - assert (postSendClear); + assertTrue(preClear); + assertTrue(postclear); + assertTrue(preSendClear); + assertTrue(postSendClear); } @@ -428,22 +428,22 @@ public void postClear(Class cls) { for (Morphium m : new Morphium[]{m1, m2}) { printstats(m); } - assert (m1.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") > 90); - assert (m2.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") > 90); + assertTrue((m1.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") > 90)); + assertTrue((m2.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") > 90)); log.info("Storing to m1 - should trigger veto, no clear on m2"); m1.store(new CachedObject("value", 100000)); TestUtils.waitForWrites(morphium, log); - assert (m2.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") != 0); - assert (m1.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") == 0); + assertTrue((m2.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") != 0)); + assertTrue((m1.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") == 0)); fillCache(m1, m2); log.info("Storing to m2 - should trigger veto, no clear on m1"); m2.store(new CachedObject("value2", 102828)); TestUtils.waitForWrites(morphium, log); - assert (m2.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") == 0); - assert (m1.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") != 0); + assertTrue((m2.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") == 0)); + assertTrue((m1.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") != 0)); cs1.detach(); cs2.detach(); @@ -490,8 +490,8 @@ public void simpleSyncTest(Morphium morphium) throws Exception { for (Morphium m : new Morphium[]{m1, m2}) { printstats(m); } - assert (m1.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") > 90); - assert (m2.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") > 90); + assertTrue((m1.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") > 90)); + assertTrue((m2.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") > 90)); log.info("Storing to m1 - waiting for m2's cache to be cleared..."); m1.store(new CachedObject("value", 100000)); @@ -530,10 +530,10 @@ public void simpleSyncTest(Morphium morphium) throws Exception { private void checkForClearedCache(Morphium m1, Morphium m2) throws Exception { printstats(m1, "X-Entries for:.*"); - assert (m1.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") == 0); - Thread.sleep(2000); + assertTrue((m1.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") == 0)); + TestUtils.waitForConditionToBecomeTrue(10000, "m2 cache was not cleared", + () -> m2.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") == 0); printstats(m1, "X-Entries for:.*"); - assertEquals(0, (double) m2.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject")); } private void fillCache(Morphium m1, Morphium m2) { @@ -632,8 +632,8 @@ public void postClear(Class cls) { m1.store(o); log.info("done."); - Thread.sleep(3000); - log.info("sleep finished " + postclear); + TestUtils.waitForConditionToBecomeTrue(10000, "clear listeners not triggered", + () -> preClear && postclear); assertFalse(preSendClear); assertFalse(postSendClear); assertTrue (postclear); @@ -679,7 +679,7 @@ public void testWatchingCacheSynchronizer(Morphium morphium) throws Exception { morphium.createQueryFor(CachedObject.class).f("counter").lte(i * 10).asList(); } - assert (morphium.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") >= 10); + assertTrue((morphium.getStatistics().get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") >= 10)); List> writings = new ArrayList<>(); Map obj = new HashMap<>(); obj.put("counter", 123); diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CappedCollectionTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CappedCollectionTest.java index aaeebecea..77aa55b49 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CappedCollectionTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CappedCollectionTest.java @@ -34,7 +34,7 @@ public void testCreationOfCappedCollection(Morphium morphium) throws Exception { cc.setStrValue("A value"); cc.setCounter(-1); morphium.store(cc); - assert(morphium.getDriver().isCapped(morphium.getConfig().connectionSettings().getDatabase(), "capped_col")); + assertTrue((morphium.getDriver().isCapped(morphium.getConfig().connectionSettings().getDatabase(), "capped_col"))); //storing more than max entries for (int i = 0; i < 1000; i++) { @@ -45,7 +45,7 @@ public void testCreationOfCappedCollection(Morphium morphium) throws Exception { } Thread.sleep(1000); - assert(morphium.createQueryFor(CappedCol.class).countAll() <= 10); + assertTrue((morphium.createQueryFor(CappedCol.class).countAll() <= 10)); for (CappedCol cp : morphium.createQueryFor(CappedCol.class).sort("counter").asIterable(10)) { log.info("Capped: " + cp.getCounter() + " - " + cp.getStrValue()); @@ -95,7 +95,7 @@ public void testListCreationOfCappedCollection(Morphium morphium) throws Excepti morphium.storeList(lst); Thread.sleep(100); - assert(morphium.getDriver().isCapped(morphium.getConfig().connectionSettings().getDatabase(), "capped_col")); + assertTrue((morphium.getDriver().isCapped(morphium.getConfig().connectionSettings().getDatabase(), "capped_col"))); assertTrue(morphium.createQueryFor(CappedCol.class).countAll() <= 10); for (CappedCol cp : morphium.createQueryFor(CappedCol.class).sort("counter").asIterable(10)) { diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ChangeStreamTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ChangeStreamTest.java index 4edffa1fa..72848eefc 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ChangeStreamTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ChangeStreamTest.java @@ -130,7 +130,7 @@ public void changeStreamBackgroundTest(Morphium morphium) throws Exception { while (!(count.get() > 0 && count.get() * 2 >= written.get() - 2)) { Thread.sleep(500); log.info(morphium.getDriver().getName() + ": Wrong count: " + count.get() + " written: " + written.get()); - assert(System.currentTimeMillis() - start < 10000); + assertTrue((System.currentTimeMillis() - start < 10000)); } log.info("finished."); @@ -300,7 +300,7 @@ public void changeStreamMonitorTest(Morphium morphium) throws Exception { Thread.sleep(5000); m.terminate(); - assert(cnt.get() >= 100 && cnt.get() <= 101) : "count is wrong: " + cnt.get(); + assertTrue((cnt.get() >= 100 && cnt.get() <= 101), () -> String.valueOf("count is wrong: " + cnt.get())); morphium.store(new UncachedObject("killing", 0)); } } @@ -381,7 +381,7 @@ public void changeStreamPipelineTest(Morphium morphium) throws Exception { if (evt.getOperationType().equals("delete")) { deletes.incrementAndGet(); } - assert(evt.getOperationType().equals("insert")); + assertTrue((evt.getOperationType().equals("insert"))); return true; }); mon.start(); @@ -393,8 +393,8 @@ public void changeStreamPipelineTest(Morphium morphium) throws Exception { morphium.createQueryFor(UncachedObject.class).setCollectionName("uncached_object").set("strValue", "updated"); morphium.delete(morphium.createQueryFor(UncachedObject.class).setCollectionName("uncached_object")); TestUtils.waitForConditionToBecomeTrue(10000, "Wrong number of inserts", () -> inserts.get() == 10); - assert(updates.get() == 0); - assert(deletes.get() == 0); + assertTrue((updates.get() == 0)); + assertTrue((deletes.get() == 0)); mon.terminate(); log.info("Resetting counters"); inserts.set(0); diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CheckForNewTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CheckForNewTest.java index 77ca6dc72..6ed91d98f 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CheckForNewTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CheckForNewTest.java @@ -14,6 +14,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Created with IntelliJ IDEA. @@ -44,22 +45,22 @@ public void testCheckForNew(Morphium morphium) { tst.theId = "2"; tst.theValue = "value2"; morphium.store(tst); - assert (tst.created == null); + assertTrue((tst.created == null)); tst = new TestID(); tst.theId = "2"; tst.theValue = "value"; morphium.store(tst); - assert (tst.created == null); + assertTrue((tst.created == null)); tst.created = new Date(); Date cr = tst.created; morphium.store(tst); - assert (cr.equals(tst.created)); + assertTrue((cr.equals(tst.created))); morphium.reread(tst); - assert (cr.equals(tst.created)); + assertTrue((cr.equals(tst.created))); morphium.getConfig().objectMappingSettings().setCheckForNew(false); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CollationTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CollationTest.java index 16bfe93a5..0e2f93ca4 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CollationTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CollationTest.java @@ -17,6 +17,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("core") public class CollationTest extends MultiDriverTestBase { @@ -41,15 +42,15 @@ public void queryTest(Morphium morphium) throws Exception { morphium.store(new UncachedObject("c", 1)); TestUtils.waitForConditionToBecomeTrue(2500, "store failed", ()->TestUtils.countUC(morphium) == 6); Collation col = new Collation("de", false, Collation.CaseFirst.LOWER, Collation.Strength.TERTIARY, false, Collation.Alternate.SHIFTED, Collation.MaxVariable.SPACE, false, false); - assert(col.getLocale().equals("de")); - assert(!col.getCaseLevel()); - assert(col.getCaseFirst().equals(Collation.CaseFirst.LOWER)); - assert(!col.getNumericOrdering()); - assert(!col.getBackwards()); - assert(!col.getNormalization()); - assert(col.getStrength().equals(Collation.Strength.TERTIARY)); - assert(col.getAlternate().equals(Collation.Alternate.SHIFTED)); - assert(col.getMaxVariable().equals(Collation.MaxVariable.SPACE)); + assertTrue((col.getLocale().equals("de"))); + assertTrue((!col.getCaseLevel())); + assertTrue((col.getCaseFirst().equals(Collation.CaseFirst.LOWER))); + assertTrue((!col.getNumericOrdering())); + assertTrue((!col.getBackwards())); + assertTrue((!col.getNormalization())); + assertTrue((col.getStrength().equals(Collation.Strength.TERTIARY))); + assertTrue((col.getAlternate().equals(Collation.Alternate.SHIFTED))); + assertTrue((col.getMaxVariable().equals(Collation.MaxVariable.SPACE))); List lst = morphium.createQueryFor(UncachedObject.class).setCollation(col).sort("strValue").asList(); String result = ""; @@ -58,7 +59,7 @@ public void queryTest(Morphium morphium) throws Exception { result += u.getStrValue(); } - assert(result.equals("aAbBcC")) : "Wrong ordering: " + result; + assertTrue((result.equals("aAbBcC")), String.valueOf("Wrong ordering: " + result)); col.normalization(true) .numericOrdering(true) .backwards(true) @@ -67,20 +68,20 @@ public void queryTest(Morphium morphium) throws Exception { .maxVariable(Collation.MaxVariable.PUNCT) .caseLevel(true) .caseFirst(Collation.CaseFirst.UPPER); - assert(col.getLocale().equals("de")); - assert(col.getCaseLevel()); - assert(col.getCaseFirst().equals(Collation.CaseFirst.UPPER)); - assert(col.getNumericOrdering()); - assert(col.getBackwards()); - assert(col.getNormalization()); - assert(col.getStrength().equals(Collation.Strength.SECONDARY)); - assert(col.getAlternate().equals(Collation.Alternate.NON_IGNORABLE)); - assert(col.getMaxVariable().equals(Collation.MaxVariable.PUNCT)); + assertTrue((col.getLocale().equals("de"))); + assertTrue((col.getCaseLevel())); + assertTrue((col.getCaseFirst().equals(Collation.CaseFirst.UPPER))); + assertTrue((col.getNumericOrdering())); + assertTrue((col.getBackwards())); + assertTrue((col.getNormalization())); + assertTrue((col.getStrength().equals(Collation.Strength.SECONDARY))); + assertTrue((col.getAlternate().equals(Collation.Alternate.NON_IGNORABLE))); + assertTrue((col.getMaxVariable().equals(Collation.MaxVariable.PUNCT))); assertNotNull(col.getMaxVariable().getMongoText()); ; assertNotNull(col.getAlternate().getMongoText()); ; - assert(col.getStrength().getMongoValue() != 0); + assertTrue((col.getStrength().getMongoValue() != 0)); assertNotNull(col.getCaseFirst().getMongoText()); ; log.info("Query: " + Utils.toJsonString(col.toQueryObject())); @@ -144,7 +145,7 @@ public void updateTest(Morphium morphium) throws Exception { }); for (UncachedObject u : q.asIterable()) { - assert(u.getCounter() == 2); + assertTrue((u.getCounter() == 2)); } } } @@ -207,7 +208,7 @@ public void aggregateTest(Morphium morphium) throws Exception { agg.collation(new Collation().locale("de").strength(Collation.Strength.PRIMARY)); agg.match(Expr.eq(Expr.field("str_value"), Expr.string("a"))); List lst = agg.aggregate(); - assert(lst.size() == 2) : "Count wrong " + lst.size(); + assertTrue((lst.size() == 2), () -> String.valueOf("Count wrong " + lst.size())); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CollectionMappingTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CollectionMappingTest.java index 6f7ba4251..00d282c94 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CollectionMappingTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CollectionMappingTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -21,9 +22,9 @@ public class CollectionMappingTest extends MultiDriverTestBase { @MethodSource("getMorphiumInstancesNoSingle") public void collectionMappingTest(Morphium morphium) { String n = morphium.getMapper().getCollectionName(CachedObject.class); - assert (n.equals("cached_object")) : "Collection wrong"; + assertTrue((n.equals("cached_object")), "Collection wrong"); n = morphium.getMapper().getCollectionName(ComplexObject.class); - assert (n.equals("ComplexObject")) : "Collection wrong"; + assertTrue((n.equals("ComplexObject")), "Collection wrong"); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CollectionNameOverrideTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CollectionNameOverrideTest.java index d339f2b0c..55399a1b6 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CollectionNameOverrideTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CollectionNameOverrideTest.java @@ -8,6 +8,7 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -49,9 +50,9 @@ public void writeAndReadCollectionNameOverride(Morphium morphium) throws Excepti Thread.sleep(1000); Query q = morphium.createQueryFor(UncachedObject.class); - assert (q.countAll() == 0); + assertTrue((q.countAll() == 0)); q.setCollectionName("uncached_collection_test_2"); - assert (q.countAll() == 1); + assertTrue((q.countAll() == 1)); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ComplexTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ComplexTest.java index 2129be914..7c41b72b0 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ComplexTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ComplexTest.java @@ -16,6 +16,7 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stpehan Bösebeck @@ -56,7 +57,7 @@ public void testStoreAndRead(Morphium morphium) { ComplexObject co2 = morphium.findById(ComplexObject.class, co.getId()); log.info("Just loaded: " + co2.toString()); log.info("Stored : " + co); - assert(co2.getId().equals(co.getId())) : "Ids not equal?"; + assertTrue((co2.getId().equals(co.getId())), "Ids not equal?"); } } @@ -71,12 +72,12 @@ public void testAccessTimestamps(Morphium morphium) throws Exception { o.setNullValue(15); //And test for null-References! morphium.store(o); - assert(o.getChanged() != 0) : "Last change not set!?!?"; + assertTrue((o.getChanged() != 0), "Last change not set!?!?"); TestUtils.waitForConditionToBecomeTrue(2000, "ComplexObject not persisted", () -> morphium.createQueryFor(ComplexObject.class).f("ein_text").eq("A test").get() != null); Query q = morphium.createQueryFor(ComplexObject.class).f("ein_text").eq("A test"); o = q.get(); - assert(o.getLastAccess() != 0) : "Last access not set!"; + assertTrue((o.getLastAccess() != 0), "Last access not set!"); o = new ComplexObject(); o.setEinText("A test2"); o.setTrans("Tansient"); @@ -84,7 +85,7 @@ public void testAccessTimestamps(Morphium morphium) throws Exception { List lst = morphium.readAll(ComplexObject.class); for (ComplexObject co : lst) { - assert(co.getChanged() != 0) : "Last Access not set!"; + assertTrue((co.getChanged() != 0), "Last Access not set!"); } } } @@ -105,28 +106,28 @@ public void testCopmplexQuery(Morphium morphium) throws Exception { Query q = morphium.createQueryFor(UncachedObject.class); q.f("counter").lt(50).or(q.q().f("counter").eq(10), q.q().f("str_value").eq("Uncached 15")); List lst = q.asList(); - assert(lst.size() == 2) : "List size wrong: " + lst.size(); + assertTrue((lst.size() == 2), String.valueOf("List size wrong: " + lst.size())); for (UncachedObject o : lst) { - assert(o.getCounter() < 50 && (o.getCounter() == 10 || o.getCounter() == 15)) : "Counter wrong: " + o.getCounter(); + assertTrue((o.getCounter() < 50 && (o.getCounter() == 10 || o.getCounter() == 15)), () -> String.valueOf("Counter wrong: " + o.getCounter())); } q = morphium.createQueryFor(UncachedObject.class); q.f("counter").lt(50).or(q.q().f("counter").eq(10), q.q().f("strValue").eq("Uncached 15"), q.q().f("counter").eq(52)); lst = q.asList(); - assert(lst.size() == 2) : "List size wrong: " + lst.size(); + assertTrue((lst.size() == 2), String.valueOf("List size wrong: " + lst.size())); for (UncachedObject o : lst) { - assert(o.getCounter() < 50 && (o.getCounter() == 10 || o.getCounter() == 15)) : "Counter wrong: " + o.getCounter(); + assertTrue((o.getCounter() < 50 && (o.getCounter() == 10 || o.getCounter() == 15)), () -> String.valueOf("Counter wrong: " + o.getCounter())); } q = morphium.createQueryFor(UncachedObject.class); q.f("counter").lt(50).f("counter").gt(10).or(q.q().f("counter").eq(22), q.q().f("str_value").eq("Uncached 15"), q.q().f("counter").gte(70)); lst = q.asList(); - assert(lst.size() == 2) : "List size wrong: " + lst.size(); + assertTrue((lst.size() == 2), String.valueOf("List size wrong: " + lst.size())); for (UncachedObject o : lst) { - assert(o.getCounter() < 50 && o.getCounter() > 10 && (o.getCounter() == 22 || o.getCounter() == 15)) : "Counter wrong: " + o.getCounter(); + assertTrue((o.getCounter() < 50 && o.getCounter() > 10 && (o.getCounter() == 22 || o.getCounter() == 15)), () -> String.valueOf("Counter wrong: " + o.getCounter())); } } } @@ -149,10 +150,10 @@ public void testNorQuery(Morphium morphium) throws Exception { q.nor(q.q().f("counter").lt(90), q.q().f("counter").gt(95)); log.info("Query: " + q.toQueryObject().toString()); List lst = q.asList(); - assert(lst.size() == 6) : "List size wrong: " + lst.size(); + assertTrue((lst.size() == 6), () -> String.valueOf("List size wrong: " + lst.size())); for (UncachedObject o : lst) { - assert(!(o.getCounter() < 90 || o.getCounter() > 95)) : "Counter wrong: " + o.getCounter(); + assertTrue((!(o.getCounter() < 90 || o.getCounter() > 95)), () -> String.valueOf("Counter wrong: " + o.getCounter())); } } } @@ -175,22 +176,22 @@ public void complexQuery(Morphium morphium) throws Exception { query.put("counter", UtilsMap.of("$lt", 10)); Query q = morphium.createQueryFor(UncachedObject.class); List lst = q.rawQuery(query).asList(); - assert(lst != null && !lst.isEmpty()) : "Nothing found?"; - assert(lst.size() == 9); + assertTrue((lst != null && !lst.isEmpty()), "Nothing found?"); + assertTrue((lst.size() == 9)); for (UncachedObject o : lst) { - assert(o.getCounter() < 10) : "Wrong counter: " + o.getCounter(); + assertTrue((o.getCounter() < 10), () -> String.valueOf("Wrong counter: " + o.getCounter())); } //test for iterator int cnt = 0; for (UncachedObject o : q.asIterable()) { - assert(o.getCounter() < 10) : "Wrong counter: " + o.getCounter(); + assertTrue((o.getCounter() < 10), () -> String.valueOf("Wrong counter: " + o.getCounter())); cnt++; } - assert(cnt == 9); + assertTrue((cnt == 9)); } } @@ -214,8 +215,8 @@ public void referenceQuery(Morphium morphium) throws Exception { qc.f("ref").eq(o); ComplexObject fnd = qc.get(); assertNotNull(fnd, "not found?!?!"); - assert(fnd.getEinText().equals(co.getEinText())) : "Text different?"; - assert(fnd.getRef().getCounter() == co.getRef().getCounter()) : "Reference broken?"; + assertTrue((fnd.getEinText().equals(co.getEinText())), "Text different?"); + assertTrue((fnd.getRef().getCounter() == co.getRef().getCounter()), "Reference broken?"); } } @@ -245,8 +246,8 @@ public void searchForSubObj(Morphium morphium) throws Exception { ; assertNotNull(co.getEmbed()); ; - assert(co.getEmbed().getName().equals("embedded1")); - assert(co.getEinText().equals("Text")); + assertTrue((co.getEmbed().getName().equals("embedded1"))); + assertTrue((co.getEinText().equals("Text"))); } } @@ -260,9 +261,9 @@ public void complexQueryCallTest(Morphium morphium) throws Exception { () -> morphium.createQueryFor(UncachedObject.class).countAll() == 100); Query q = morphium.createQueryFor(UncachedObject.class); UncachedObject uc = q.rawQuery(UtilsMap.of("counter", 10)).asList().get(0); - assert(uc.getCounter() == 10); - assert(q.q().rawQuery(UtilsMap.of("counter", UtilsMap.of("$lte", 50))).countAll() == 51); // 0-50 inclusive = 51 - assert(q.q().rawQuery(UtilsMap.of("counter", UtilsMap.of("$lte", 50))).asList().size() == 51); + assertTrue((uc.getCounter() == 10)); + assertTrue((q.q().rawQuery(UtilsMap.of("counter", UtilsMap.of("$lte", 50))).countAll() == 51)); // 0-50 inclusive = 51 + assertTrue((q.q().rawQuery(UtilsMap.of("counter", UtilsMap.of("$lte", 50))).asList().size() == 51)); } } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CustomCollectionNameTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CustomCollectionNameTest.java index c39fa89cb..378ea88ca 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CustomCollectionNameTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CustomCollectionNameTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -37,8 +38,8 @@ public void testUpdateInOtherCollection(Morphium morphium) throws Exception { Query q = m.createQueryFor(EntityCollectionName.class).f("value").eq(1); q.setCollectionName(collectionName); EntityCollectionName eFetched = q.get(); -assert eFetched != null : "fetched before update"; -assert eFetched.value == 1 : "fetched s2:"; +assertTrue(eFetched != null, "fetched before update"); +assertTrue(eFetched.value == 1, "fetched s2:"); e.value = 2; m.updateUsingFields(e, collectionName, null, new String[] {"value"}); Query q2 = m.createQueryFor(EntityCollectionName.class).f("value").eq(2); @@ -62,7 +63,7 @@ public void testDeleteInOtherCollection(Morphium morphium) throws Exception { // Wait for store to be visible on replica sets TestUtils.waitForConditionToBecomeTrue(10000, "Store not visible", () -> q.get() != null); EntityCollectionName eFetched = q.get(); - assert eFetched != null : "fetched before delete"; + assertTrue(eFetched != null, "fetched before delete"); m.delete(q, (AsyncOperationCallback) null); // Wait for delete to be visible (replication lag on replica sets) TestUtils.waitForConditionToBecomeTrue(10000, "Delete not visible", () -> q.get() == null); diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CustomMapperTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CustomMapperTest.java index fc129cf9a..a8861a799 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CustomMapperTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/CustomMapperTest.java @@ -165,9 +165,9 @@ public void BsonGeoMapperTest(Morphium morphium) { Object marshalled = m.marshall(g); Geo res = m.unmarshall(marshalled); assertNotNull(res.getType());; - assert(res.getType().equals(GeoType.POINT)); - assert(((List) res.getCoordinates()).get(0).equals(12.0)); - assert(((List) res.getCoordinates()).get(1).equals(13.0)); + assertTrue((res.getType().equals(GeoType.POINT))); + assertTrue((((List) res.getCoordinates()).get(0).equals(12.0))); + assertTrue((((List) res.getCoordinates()).get(1).equals(13.0))); } @ParameterizedTest @@ -187,15 +187,15 @@ public void customMappedObjectTest(Morphium morphium) { assertNotNull(readContainingObject.getCustomMappedObject(), "Custom mapped object null?"); assertNotNull(readContainingObject.getCustomMappedObjectList(), "List of custom mapped object null?"); assertNotNull(readContainingObject.getCustomMappedObjectMap(), "Map with custom mapped object null?"); - assert(readContainingObject.getCustomMappedObjectList().size() == 2) : "List of custom mapped objects has wrong size? size is " + readContainingObject.getCustomMappedObjectList().size(); - assert(readContainingObject.getCustomMappedObjectMap().size() == 2) : "Map with custom mapped objects as value has wrong size?"; - assert(readContainingObject.getCustomMappedObject().equals(containingObject.getCustomMappedObject())) : "Single custom mapped objects differ?"; + assertTrue((readContainingObject.getCustomMappedObjectList().size() == 2), () -> String.valueOf("List of custom mapped objects has wrong size? size is " + readContainingObject.getCustomMappedObjectList().size())); + assertTrue((readContainingObject.getCustomMappedObjectMap().size() == 2), "Map with custom mapped objects as value has wrong size?"); + assertTrue((readContainingObject.getCustomMappedObject().equals(containingObject.getCustomMappedObject())), "Single custom mapped objects differ?"); for (int i = 0; i < 2; i++) { CustomMappedObject referenceObject = containingObject.getCustomMappedObjectList().get(i); assertNotNull(readContainingObject.getCustomMappedObjectList().get(i), "Custom mapped object in list missing? - " + i); - assert(readContainingObject.getCustomMappedObjectList().get(i).equals(referenceObject)) : "Custom mapped objects in list differ? - " + i; - assert(readContainingObject.getCustomMappedObjectMap().get(referenceObject.getName()).equals(map.get(referenceObject.getName()))) : "Custom mapped objects in map differ? - " + i; + assertTrue((readContainingObject.getCustomMappedObjectList().get(i).equals(referenceObject)), String.valueOf("Custom mapped objects in list differ? - " + i)); + assertTrue((readContainingObject.getCustomMappedObjectMap().get(referenceObject.getName()).equals(map.get(referenceObject.getName()))), String.valueOf("Custom mapped objects in map differ? - " + i)); } morphium.getMapper().deregisterCustomMapperFor(CustomMappedObject.class); @@ -293,14 +293,14 @@ public void complexCustomMappingTest(Morphium morphium) { assertNotNull(readContainingObject.getComplexMap(), "Complex map object null?"); assertNotNull(readContainingObject.getComplexestList(), "Complexest list object null?"); assertNotNull(readContainingObject.getComplexestMap(), "Complexest map object null?"); - assert(readContainingObject.getComplexList().size() == 1) : "Complex list has wrong size?"; - assert(readContainingObject.getComplexMap().size() == 1) : "Complex map has wrong size?"; - assert(readContainingObject.getComplexestList().size() == 1) : "Complexest list has wrong size?"; - assert(readContainingObject.getComplexestMap().size() == 1) : "Complexest map has wrong size?"; - assert(readContainingObject.getComplexList().equals(complexList)) : "Complex lists differ?"; - assert(readContainingObject.getComplexMap().equals(complexMap)) : "Complex maps differ?"; - assert(readContainingObject.getComplexestList().equals(complexestList)) : "Complexest lists differ?"; - assert(readContainingObject.getComplexestMap().equals(complexestMap)) : "Complexest maps differ?"; + assertTrue((readContainingObject.getComplexList().size() == 1), "Complex list has wrong size?"); + assertTrue((readContainingObject.getComplexMap().size() == 1), "Complex map has wrong size?"); + assertTrue((readContainingObject.getComplexestList().size() == 1), "Complexest list has wrong size?"); + assertTrue((readContainingObject.getComplexestMap().size() == 1), "Complexest map has wrong size?"); + assertTrue((readContainingObject.getComplexList().equals(complexList)), "Complex lists differ?"); + assertTrue((readContainingObject.getComplexMap().equals(complexMap)), "Complex maps differ?"); + assertTrue((readContainingObject.getComplexestList().equals(complexestList)), "Complexest lists differ?"); + assertTrue((readContainingObject.getComplexestMap().equals(complexestMap)), "Complexest maps differ?"); morphium.getMapper().deregisterCustomMapperFor(CustomMappedObject.class); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/DAOTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/DAOTest.java index ae3bc3d39..a17e83240 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/DAOTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/DAOTest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -33,21 +34,21 @@ public void daoTest(Morphium morphium) throws Exception { Thread.sleep(1000); UncachedObjectDAO dao = new UncachedObjectDAO(morphium); List lst = dao.getAll(); - assert (lst.size() == 100) : "Wrong element count: " + lst.size(); + assertTrue(lst.size() == 100, "Wrong element count: " + lst.size()); lst = dao.findByField(UncachedObjectDAO.Field.counter, 55); - assert (lst.size() == 1) : "Wrong element count in find: " + lst.size(); + assertTrue(lst.size() == 1, "Wrong element count in find: " + lst.size()); - assert (lst.get(0).getCounter() == 55) : "Got wrong element: " + lst.get(0).getCounter(); + assertTrue(lst.get(0).getCounter() == 55, "Got wrong element: " + lst.get(0).getCounter()); assertNotNull(dao.getValue(UncachedObjectDAO.Field.counter, lst.get(0))); ; assertNotNull(dao.getValue("counter", lst.get(0))); ; - assert (dao.existsField("str_value")); + assertTrue((dao.existsField("str_value"))); dao.setValue(UncachedObjectDAO.Field.counter, 12, lst.get(0)); - assert (lst.get(0).getCounter() == 12) : "Got wrong element: " + lst.get(0).getCounter(); + assertTrue(lst.get(0).getCounter() == 12, "Got wrong element: " + lst.get(0).getCounter()); dao.setValue("counter", 13, lst.get(0)); - assert (lst.get(0).getCounter() == 13) : "Got wrong element: " + lst.get(0).getCounter(); + assertTrue(lst.get(0).getCounter() == 13, "Got wrong element: " + lst.get(0).getCounter()); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/DataTypeTests.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/DataTypeTests.java index 5a003e5b7..c9830422d 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/DataTypeTests.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/DataTypeTests.java @@ -41,7 +41,8 @@ public void listOperationsTest(Morphium morphium) throws Exception { lc.addString("String2"); morphium.store(lc); - Thread.sleep(1000); + TestUtils.waitForConditionToBecomeTrue(5000, "ListContainer was not stored", + () -> morphium.createQueryFor(ListContainer.class).countAll() == 1); ListContainer stored = morphium.createQueryFor(ListContainer.class).get(); assertNotNull(stored); assertEquals(3, stored.getLongList().size()); @@ -57,17 +58,23 @@ public void listOperationsTest(Morphium morphium) throws Exception { // Test adding to existing list stored.addLong(4L); morphium.store(stored); - Thread.sleep(1000); + TestUtils.waitForConditionToBecomeTrue(5000, "Added long not visible", + () -> { + var r = morphium.createQueryFor(ListContainer.class).get(); + return r != null && r.getLongList().size() == 4; + }); ListContainer updated = morphium.createQueryFor(ListContainer.class).get(); - assertEquals(4, updated.getLongList().size()); assertTrue(updated.getLongList().contains(4L)); // Test list removal updated.getLongList().remove(Long.valueOf(1L)); morphium.store(updated); - Thread.sleep(1000); + TestUtils.waitForConditionToBecomeTrue(5000, "Removed long still visible", + () -> { + var r = morphium.createQueryFor(ListContainer.class).get(); + return r != null && r.getLongList().size() == 3; + }); ListContainer removed = morphium.createQueryFor(ListContainer.class).get(); - assertEquals(3, removed.getLongList().size()); assertFalse(removed.getLongList().contains(1L)); } } @@ -94,7 +101,8 @@ public void nestedListTest(Morphium morphium) throws Exception { morphium.store(entity); - Thread.sleep(1000); + TestUtils.waitForConditionToBecomeTrue(5000, "NestedListEntity was not stored", + () -> morphium.createQueryFor(NestedListEntity.class).countAll() == 1); NestedListEntity stored = morphium.createQueryFor(NestedListEntity.class).get(); assertNotNull(stored); assertEquals(3, stored.listOfLists.size()); @@ -131,7 +139,8 @@ public void setOperationsTest(Morphium morphium) throws Exception { entity.intSet.add(2); // Duplicate - should be ignored morphium.store(entity); - Thread.sleep(1000); + TestUtils.waitForConditionToBecomeTrue(5000, "SetEntity was not stored", + () -> morphium.createQueryFor(SetEntity.class).countAll() == 1); SetEntity stored = morphium.createQueryFor(SetEntity.class).get(); assertNotNull(stored); assertEquals(3, stored.stringSet.size()); @@ -148,10 +157,13 @@ public void setOperationsTest(Morphium morphium) throws Exception { stored.stringSet.add("value4"); stored.stringSet.remove("value1"); morphium.store(stored); - Thread.sleep(1000); + TestUtils.waitForConditionToBecomeTrue(5000, "Set modification not visible", + () -> { + var r = morphium.createQueryFor(SetEntity.class).get(); + return r != null && r.stringSet.contains("value4"); + }); SetEntity modified = morphium.createQueryFor(SetEntity.class).get(); assertEquals(3, modified.stringSet.size()); - assertTrue(modified.stringSet.contains("value4")); assertFalse(modified.stringSet.contains("value1")); } } @@ -177,7 +189,8 @@ public void mapOperationsTest(Morphium morphium) throws Exception { morphium.store(entity); - Thread.sleep(1000); + TestUtils.waitForConditionToBecomeTrue(5000, "MapEntity was not stored", + () -> morphium.createQueryFor(MapEntity.class).countAll() == 1); MapEntity stored = morphium.createQueryFor(MapEntity.class).get(); assertNotNull(stored); assertEquals(3, stored.stringMap.size()); @@ -195,11 +208,14 @@ public void mapOperationsTest(Morphium morphium) throws Exception { stored.stringMap.remove("key1"); stored.intMap.put("counter2", 25); morphium.store(stored); - Thread.sleep(1000); + TestUtils.waitForConditionToBecomeTrue(5000, "Map modification not visible", + () -> { + var r = morphium.createQueryFor(MapEntity.class).get(); + return r != null && r.stringMap.containsKey("key4"); + }); MapEntity modified = morphium.createQueryFor(MapEntity.class).get(); assertEquals(3, modified.stringMap.size()); - assertTrue(modified.stringMap.containsKey("key4")); assertFalse(modified.stringMap.containsKey("key1")); assertEquals(Integer.valueOf(25), modified.intMap.get("counter2")); } @@ -223,7 +239,8 @@ public void enumOperationsTest(Morphium morphium) throws Exception { entity.statusList.add(TestStatus.PENDING); morphium.store(entity); - Thread.sleep(1000); + TestUtils.waitForConditionToBecomeTrue(5000, "EnumEntity was not stored", + () -> morphium.createQueryFor(EnumEntity.class).countAll() == 1); EnumEntity stored = morphium.createQueryFor(EnumEntity.class).get(); assertNotNull(stored); assertEquals(TestStatus.ACTIVE, stored.status); @@ -250,9 +267,12 @@ public void enumOperationsTest(Morphium morphium) throws Exception { stored.statusList.add(TestStatus.COMPLETED); morphium.store(stored); - Thread.sleep(1000); + TestUtils.waitForConditionToBecomeTrue(5000, "Enum update not visible", + () -> { + var r = morphium.createQueryFor(EnumEntity.class).get(); + return r != null && r.status == TestStatus.COMPLETED; + }); EnumEntity updated = morphium.createQueryFor(EnumEntity.class).get(); - assertEquals(TestStatus.COMPLETED, updated.status); assertEquals(4, updated.statusList.size()); assertTrue(updated.statusList.contains(TestStatus.COMPLETED)); } @@ -274,7 +294,8 @@ public void binaryDataTest(Morphium morphium) throws Exception { entity.description = "Binary test"; morphium.store(entity); - Thread.sleep(1000); + TestUtils.waitForConditionToBecomeTrue(5000, "BinaryDataEntity was not stored", + () -> morphium.createQueryFor(BinaryDataEntity.class).countAll() == 1); BinaryDataEntity stored = morphium.createQueryFor(BinaryDataEntity.class).get(); assertNotNull(stored); assertNotNull(stored.binaryData); @@ -285,18 +306,24 @@ public void binaryDataTest(Morphium morphium) throws Exception { byte[] newData = "Updated binary data".getBytes(); stored.binaryData = newData; morphium.store(stored); - Thread.sleep(1000); + TestUtils.waitForConditionToBecomeTrue(5000, "Binary data update not visible", + () -> { + var r = morphium.createQueryFor(BinaryDataEntity.class).get(); + return r != null && Arrays.equals(newData, r.binaryData); + }); BinaryDataEntity updated = morphium.createQueryFor(BinaryDataEntity.class).get(); - assertArrayEquals(newData, updated.binaryData); // Test large binary data byte[] largeData = new byte[10000]; Arrays.fill(largeData, (byte) 42); updated.binaryData = largeData; morphium.store(updated); - Thread.sleep(1000); + TestUtils.waitForConditionToBecomeTrue(5000, "Large binary data not visible", + () -> { + var r = morphium.createQueryFor(BinaryDataEntity.class).get(); + return r != null && r.binaryData.length == 10000; + }); BinaryDataEntity withLargeData = morphium.createQueryFor(BinaryDataEntity.class).get(); - assertEquals(10000, withLargeData.binaryData.length); assertEquals(42, withLargeData.binaryData[5000]); } } @@ -318,7 +345,8 @@ public void arrayOfPrimitivesTest(Morphium morphium) throws Exception { entity.stringArray = new String[] {"a", "b", "c", "d", "e"}; morphium.store(entity); - Thread.sleep(1000); + TestUtils.waitForConditionToBecomeTrue(5000, "PrimitiveArrayEntity was not stored", + () -> morphium.createQueryFor(PrimitiveArrayEntity.class).countAll() == 1); PrimitiveArrayEntity stored = morphium.createQueryFor(PrimitiveArrayEntity.class).get(); assertNotNull(stored); assertArrayEquals(new int[] {1, 2, 3, 4, 5}, stored.intArray); @@ -335,9 +363,12 @@ public void arrayOfPrimitivesTest(Morphium morphium) throws Exception { stored.intArray[2] = 33; stored.stringArray[1] = "modified"; morphium.store(stored); - Thread.sleep(1000); + TestUtils.waitForConditionToBecomeTrue(5000, "Array update not visible", + () -> { + var r = morphium.createQueryFor(PrimitiveArrayEntity.class).get(); + return r != null && r.intArray[2] == 33; + }); PrimitiveArrayEntity updated = morphium.createQueryFor(PrimitiveArrayEntity.class).get(); - assertEquals(33, updated.intArray[2]); assertEquals("modified", updated.stringArray[1]); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/DeleteTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/DeleteTest.java index 5a2d20f79..860db560d 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/DeleteTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/DeleteTest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.params.provider.MethodSource; import java.util.List; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -30,7 +31,7 @@ public void uncachedDeleteSingle(Morphium morphium) throws Exception { TestUtils.waitForConditionToBecomeTrue(1000, "delete failed", () -> TestUtils.countUC(morphium) == 9); List lst = morphium.createQueryFor(UncachedObject.class).asList(); for (UncachedObject uc : lst) { - assert (!uc.getMorphiumId().equals(u.getMorphiumId())); + assertTrue((!uc.getMorphiumId().equals(u.getMorphiumId()))); } } } @@ -46,7 +47,7 @@ public void uncachedDeleteQuery(Morphium morphium) throws Exception { TestUtils.waitForConditionToBecomeTrue(1000, "delete failed", () -> TestUtils.countUC(morphium) == 9); List lst = morphium.createQueryFor(UncachedObject.class).asList(); for (UncachedObject uc : lst) { - assert (!uc.getMorphiumId().equals(u.getMorphiumId())); + assertTrue((!uc.getMorphiumId().equals(u.getMorphiumId()))); } } } @@ -59,7 +60,7 @@ public void cachedDeleteSingle(Morphium morphium) throws Exception { createCachedObjects(morphium, 10); TestUtils.waitForWrites(morphium, log); long c = morphium.createQueryFor(CachedObject.class).countAll(); - assert (c == 10) : "Count is " + c; + assertTrue((c == 10), String.valueOf("Count is " + c)); CachedObject u = morphium.createQueryFor(CachedObject.class).get(); morphium.delete(u); TestUtils.waitForWrites(morphium, log); @@ -72,10 +73,10 @@ public void cachedDeleteSingle(Morphium morphium) throws Exception { } c = morphium.createQueryFor(CachedObject.class).countAll(); - assert (c == 9); + assertTrue((c == 9)); List lst = morphium.createQueryFor(CachedObject.class).asList(); for (CachedObject uc : lst) { - assert (!uc.getId().equals(u.getId())); + assertTrue((!uc.getId().equals(u.getId()))); } } } @@ -87,7 +88,7 @@ public void cachedDeleteQuery(Morphium morphium) throws Exception { createCachedObjects(morphium, 10); TestUtils.waitForWrites(morphium, log); long cnt = morphium.createQueryFor(CachedObject.class).countAll(); - assert (cnt == 10) : "Count is " + cnt; + assertTrue((cnt == 10), String.valueOf("Count is " + cnt)); CachedObject co = morphium.createQueryFor(CachedObject.class).get(); morphium.delete(morphium.createQueryFor(CachedObject.class).f("counter").eq(co.getCounter())); TestUtils.waitForWrites(morphium, log); @@ -95,10 +96,10 @@ public void cachedDeleteQuery(Morphium morphium) throws Exception { TestUtils.waitForConditionToBecomeTrue(10000, "Delete not visible", () -> morphium.createQueryFor(CachedObject.class).countAll() == 9); cnt = morphium.createQueryFor(CachedObject.class).countAll(); - assert (cnt == 9); + assertTrue((cnt == 9)); List lst = morphium.createQueryFor(CachedObject.class).asList(); for (CachedObject c : lst) { - assert (!c.getId().equals(co.getId())); + assertTrue((!c.getId().equals(co.getId()))); } } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/DistinctGroupTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/DistinctGroupTest.java index e30d0acfb..b52afd545 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/DistinctGroupTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/DistinctGroupTest.java @@ -9,6 +9,7 @@ import java.util.ArrayList; import java.util.List; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -32,12 +33,12 @@ public void distinctTest(Morphium morphium) throws Exception { morphium.storeList(lst); Thread.sleep(500); List values = morphium.distinct("counter", UncachedObject.class); - assert (values.size() == 3) : "Size wrong: " + values.size(); + assertTrue((values.size() == 3), String.valueOf("Size wrong: " + values.size())); for (Object o : values) { log.info("counter: " + o.toString()); } values = morphium.distinct("str_value", UncachedObject.class); - assert (values.size() == 2) : "Size wrong: " + values.size(); + assertTrue((values.size() == 2), String.valueOf("Size wrong: " + values.size())); for (Object o : values) { log.info("Value: " + o.toString()); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/DistinctTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/DistinctTest.java index 29bde7e27..fe2c70e23 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/DistinctTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/DistinctTest.java @@ -57,9 +57,9 @@ public void distinctTest(Morphium morphium) { createUncachedObjects(morphium, 100); List lst = morphium.createQueryFor(UncachedObject.class).distinct("counter"); - assert (lst.size() == 100); + assertTrue((lst.size() == 100)); lst = morphium.createQueryFor(UncachedObject.class).distinct("str_value"); - assert (lst.size() == 1); + assertTrue((lst.size() == 1)); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/EnumTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/EnumTest.java index 719fba896..9470676f0 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/EnumTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/EnumTest.java @@ -15,6 +15,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -37,10 +38,10 @@ public void enumTest(Morphium morphium) throws InterruptedException { Thread.sleep(150); ent = morphium.createQueryFor(EnumEntity.class).f("value").eq("ein Test").get(); assertNotNull(ent.getTst(), "Enum is null!"); - assert(ent.getTst().equals(TestEnum.TEST1)) : "Enum error!"; + assertTrue((ent.getTst().equals(TestEnum.TEST1)), "Enum error!"); ent = morphium.createQueryFor(EnumEntity.class).f("tst").eq(TestEnum.TEST1).get(); assertNotNull(ent.getTst(), "Enum is null!"); - assert(ent.getTst().equals(TestEnum.TEST1)) : "Enum error!"; + assertTrue((ent.getTst().equals(TestEnum.TEST1)), "Enum error!"); } @ParameterizedTest @@ -59,14 +60,14 @@ public void enumListTest(Morphium morphium) throws InterruptedException { Thread.sleep(150); EnumEntity ent2 = morphium.createQueryFor(EnumEntity.class).f("value").eq("ein Test").get(); assertNotNull(ent2.getTst(), "Enum is null!"); - assert(ent2.getTst().equals(TestEnum.TEST1)) : "Enum error!"; + assertTrue((ent2.getTst().equals(TestEnum.TEST1)), "Enum error!"); ent2 = morphium.createQueryFor(EnumEntity.class).f("tst").eq(TestEnum.TEST1).get(); assertNotNull(ent2.getTst(), "Enum is null!"); - assert(ent2.getTst().equals(TestEnum.TEST1)) : "Enum error!"; - assert(ent2.getTstLst().size() == 3) : "Size of testlist wrong: " + ent2.getTstLst().size(); + assertTrue((ent2.getTst().equals(TestEnum.TEST1)), "Enum error!"); + assertTrue((ent2.getTstLst().size() == 3), String.valueOf("Size of testlist wrong: " + ent2.getTstLst().size())); for (int i = 0; i < ent2.getTstLst().size(); i++) { - assert(ent2.getTstLst().get(i).equals(ent.getTstLst().get(i))) : "Enums differ?!?!? " + ent.getTstLst().get(i).name() + "!=" + ent2.getTstLst().get(i).name(); + assertTrue((ent2.getTstLst().get(i).equals(ent.getTstLst().get(i))), String.valueOf("Enums differ?!?!? " + ent.getTstLst().get(i).name() + "!=" + ent2.getTstLst().get(i).name())); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ExpEvaluationTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ExpEvaluationTest.java index 3f16df2c0..556083571 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ExpEvaluationTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ExpEvaluationTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.Test; import java.util.Map; +import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("core") public class ExpEvaluationTest { @@ -17,7 +18,7 @@ public void fieldExprTest() { Expr f = Expr.field("fld1"); Object v = f.evaluate(context); - assert (v.equals(context.get("fld1"))); + assertTrue((v.equals(context.get("fld1")))); } @@ -25,6 +26,6 @@ public void fieldExprTest() { public void divideTest() { Map context = UtilsMap.of("fld1", (Object) 42, "fld2", 2); Object r = Expr.divide(Expr.field("fld1"), Expr.intExpr(3)).evaluate(context); - assert (r != null && r.equals(14.0)); + assertTrue((r != null && r.equals(14.0))); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ExprParsingTests.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ExprParsingTests.java index 0d20d3dab..4df7f321d 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ExprParsingTests.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ExprParsingTests.java @@ -11,6 +11,7 @@ import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.*; +import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("core") public class ExprParsingTests { @@ -24,7 +25,7 @@ public void parseMod() { Expr add = Expr.parse(qo); Map context = UtilsMap.of("field", 12); Object result = add.evaluate(context); - assert (result.equals(2.0)); + assertTrue((result.equals(2.0))); log.info("done"); } @Test @@ -35,7 +36,7 @@ public void parseAdd() { Expr add = Expr.parse(qo); Map context = UtilsMap.of("field", 12); Object result = add.evaluate(context); - assert (result.equals(47.0)); + assertTrue((result.equals(47.0))); log.info("done"); } @@ -43,9 +44,9 @@ public void parseAdd() { public void backAndForthTest() { Expr o = Expr.abs(Expr.intExpr(1)); Expr o2 = Expr.parse(o.toQueryObject()); - assert (o.toQueryObject().equals(o2.toQueryObject())); + assertTrue((o.toQueryObject().equals(o2.toQueryObject()))); Map context = UtilsMap.of("test", 1); - assert (o.evaluate(context).equals(o2.evaluate(context))); + assertTrue((o.evaluate(context).equals(o2.evaluate(context)))); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/FieldListTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/FieldListTest.java index fb213e8fa..501fa989b 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/FieldListTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/FieldListTest.java @@ -16,6 +16,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Created with IntelliJ IDEA. @@ -38,7 +39,7 @@ public void testFieldList(Morphium morphium) { q = q.f(UncachedObject.Fields.counter).eq(30); UncachedObject uc = q.get(); - assert (uc.getStrValue() == null) : "Value is " + uc.getStrValue(); + assertTrue((uc.getStrValue() == null), () -> String.valueOf("Value is " + uc.getStrValue())); } @ParameterizedTest @@ -60,7 +61,7 @@ public void testReadOnly(Morphium morphium) throws Exception { ro.readOnlyValue = "must still not be stored, even after update!"; morphium.store(ro); morphium.reread(ro); - assert (ro.readOnlyValue == null); + assertTrue((ro.readOnlyValue == null)); //forcing store of a value Map marshall = morphium.getMapper().serialize(ro); @@ -73,11 +74,11 @@ public void testReadOnly(Morphium morphium) throws Exception { cmd.releaseConnection(); Thread.sleep(100); morphium.reread(ro); - assert (ro.readOnlyValue.equals("stored in db")); + assertTrue((ro.readOnlyValue.equals("stored in db"))); ro.readOnlyValue = "different"; morphium.reread(ro); - assert (ro.readOnlyValue.equals("stored in db")); + assertTrue((ro.readOnlyValue.equals("stored in db"))); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/FieldShadowingTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/FieldShadowingTest.java index 4cfd6c790..ca82bb807 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/FieldShadowingTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/FieldShadowingTest.java @@ -10,6 +10,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; @SuppressWarnings("AssertWithSideEffects") @Tag("core") @@ -22,11 +23,11 @@ public void shadowFieldTest(Morphium morphium) throws Exception { it.value = "A test"; String marshall = Utils.toJsonString(morphium.getMapper().serialize(it)); log.info(marshall); - assert (marshall.contains("A test")); + assertTrue((marshall.contains("A test"))); assertNotNull(morphium.getMapper().deserialize(Shadowed.class, marshall).value); ; - assert (morphium.getMapper().deserialize(Shadowed.class, marshall).value.equals("A test")); + assertTrue((morphium.getMapper().deserialize(Shadowed.class, marshall).value.equals("A test"))); ReShadowed rs = new ReShadowed(); rs.value = "A 2nd test"; diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/FilterExpressionTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/FilterExpressionTest.java index 9e390f4be..bb109c2f1 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/FilterExpressionTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/FilterExpressionTest.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Hans Karlsson @@ -33,21 +34,21 @@ public void setup() { public void testNullValue() { fe.setValue(null); Map dbObject = fe.dbObject(); - assert (dbObject.containsKey("field")); - assert (dbObject.get("field") == null); + assertTrue((dbObject.containsKey("field"))); + assertTrue((dbObject.get("field") == null)); } @Test public void testAddTwoChildren() { fe.addChild(createChild1()); fe.addChild(createChild2()); - assert (fe.getChildren().size() == 2); + assertTrue((fe.getChildren().size() == 2)); } @Test public void testAddListWithTwoChildren() { fe.setChildren(createChildrenList()); - assert (fe.getChildren().size() == 2); + assertTrue((fe.getChildren().size() == 2)); } @Test @@ -56,9 +57,9 @@ public void testDBObjectWithSingleValue() { String key = (String) map.keySet().iterator().next(); String value = (String) map.values().iterator().next(); - assert (map.keySet().size() == 1); - assert ("field".equals(key)); - assert ("value".equals(value)); + assertTrue((map.keySet().size() == 1)); + assertTrue(("field".equals(key))); + assertTrue(("value".equals(value))); } private enum TestEnum { @@ -76,9 +77,9 @@ public void testDBObjectWithSingleEnumAsValue() { String key = (String) map.keySet().iterator().next(); String value = (String) map.values().iterator().next(); - assert (map.keySet().size() == 1); - assert ("field".equals(key)); - assert (testEnum.name().equals(value)); + assertTrue((map.keySet().size() == 1)); + assertTrue(("field".equals(key))); + assertTrue((testEnum.name().equals(value))); } @Test @@ -86,18 +87,18 @@ public void testDBObjectWithTwoChildren() { fe.addChild(createChild1()); fe.addChild(createChild2()); - assert ("field".equals(fe.getField())); + assertTrue(("field".equals(fe.getField()))); Map map = fe.dbObject(); - assert (map.keySet().size() == 1); - assert (map.keySet().iterator().next().equals("field")); - assert (map.values().size() == 1); + assertTrue((map.keySet().size() == 1)); + assertTrue((map.keySet().iterator().next().equals("field"))); + assertTrue((map.values().size() == 1)); Set fetchedKeys = ((Map) map.values().iterator().next()).keySet(); - assert (fetchedKeys.contains("child1Field") && fetchedKeys.contains("child2Field")); - assert (((Map) map.values().iterator().next()).get("child1Field").equals("child1Value")); - assert (((Map) map.values().iterator().next()).get("child2Field").equals("child2Value")); + assertTrue((fetchedKeys.contains("child1Field") && fetchedKeys.contains("child2Field"))); + assertTrue((((Map) map.values().iterator().next()).get("child1Field").equals("child1Value"))); + assertTrue((((Map) map.values().iterator().next()).get("child2Field").equals("child2Value"))); } @Test @@ -111,7 +112,7 @@ public void testAddChildTwoTimesShouldBeEquivalentWithAddChildren() { fe2.setField("field"); fe2.setChildren(createChildrenList()); - assert (fe1.dbObject().equals(fe2.dbObject())); + assertTrue((fe1.dbObject().equals(fe2.dbObject()))); } private List createChildrenList() { diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/HierarchyTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/HierarchyTest.java index 0cd4f8198..4286daa49 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/HierarchyTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/HierarchyTest.java @@ -10,6 +10,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -35,9 +36,9 @@ public void setAdditionalProperty(String additionalProperty) { @ParameterizedTest @MethodSource("getMorphiumInstancesNoSingle") public void testHierarchy(Morphium morphium) { - assert (new AnnotationAndReflectionHelper(true).isAnnotationPresentInHierarchy(SubClass.class, Entity.class)) : "hierarchy not found"; + assertTrue((new AnnotationAndReflectionHelper(true).isAnnotationPresentInHierarchy(SubClass.class, Entity.class)), "hierarchy not found"); String n = new ObjectMapperImpl().getCollectionName(HierarchyTest.SubClass.class); - assert (!n.equals("uncached_object")) : "Wrong collection name!"; + assertTrue((!n.equals("uncached_object")), "Wrong collection name!"); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/IDConversionTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/IDConversionTest.java index 292f6d429..3d9e51dbf 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/IDConversionTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/IDConversionTest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -28,12 +29,12 @@ public void testIdConversion(Morphium morphium) { qu.f("_id").eq(new MorphiumId().toString()); System.out.println(qu.toQueryObject().toString()); - assert (qu.toQueryObject().toString().contains("_id=")); + assertTrue((qu.toQueryObject().toString().contains("_id="))); qu = new Query(morphium, UncachedObject.class, null); qu.setCollectionName("uncached"); qu.f("str_value").eq(new MorphiumId()); System.out.println(qu.toQueryObject().toString()); - assert (!qu.toQueryObject().toString().contains("_id=")); + assertTrue((!qu.toQueryObject().toString().contains("_id="))); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/IdCacheTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/IdCacheTest.java index fc119b2fa..b5a18bb4e 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/IdCacheTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/IdCacheTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -64,20 +65,20 @@ public void idTest(Morphium morphium) throws Exception { List lst = q.asList(); String k = morphium.getCache().getCacheKey(q); - assert (lst.size() == 29) : "Size matters! " + lst.size(); + assertTrue((lst.size() == 29), () -> String.valueOf("Size matters! " + lst.size())); Thread.sleep(1100); Map sizes = morphium.getCache().getSizes(); MorphiumId id = lst.get(0).getId(); CachedObject c = morphium.findById(CachedObject.class, id); - assert (lst.get(0) == c) : "Object differ?"; + assertTrue((lst.get(0) == c), "Object differ?"); c.setCounter(1009); - assert (lst.get(0).getCounter() == 1009) : "changes not work?"; + assertTrue((lst.get(0).getCounter() == 1009), "changes not work?"); morphium.reread(c); - assert (c.getCounter() != 1009) : "reread did not work?"; + assertTrue((c.getCounter() != 1009), "reread did not work?"); - assert (lst.get(0) == c) : "Object changed?!?!?"; + assertTrue((lst.get(0) == c), "Object changed?!?!?"); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/IndexDescriptionTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/IndexDescriptionTest.java index 5d2a449a5..b6e9ffb1f 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/IndexDescriptionTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/IndexDescriptionTest.java @@ -46,4 +46,30 @@ public void asMapFromMapTest() throws Exception { assertEquals(idx.getHidden(), idx2.getHidden()); assertEquals(idx.getSparse(), idx2.getSparse()); } + + // Regression test: fromMap() used to append a trailing "_" separator after every key + // instead of only BETWEEN keys, producing names like "campaignNumber_1_" instead of the + // MongoDB-standard "campaignNumber_1". That mismatch breaks index creation on any database + // where the correctly-named index already exists (MongoDB rejects it with "Error 85 - Index + // already exists with a different name", which Morphium only logs as a warning). Neither + // pre-existing test above catches this: both set an explicit name, which skips the + // auto-naming branch entirely. + @Test + public void fromMap_singleField_generatesNameWithoutTrailingUnderscore() throws Exception { + var idx = IndexDescription.fromMaps(Doc.of("campaignNumber", 1), null); + assertEquals("campaignNumber_1", idx.getName()); + } + + @Test + public void fromMap_multiField_generatesNameJoinedByUnderscoreWithoutTrailingUnderscore() throws Exception { + var idx = IndexDescription.fromMaps(Doc.of("campaignNumber", 1, "fileName", 1), null); + assertEquals("campaignNumber_1_fileName_1", idx.getName()); + } + + @Test + public void fromMap_explicitName_isNotOverwritten() throws Exception { + var idx = IndexDescription.fromMaps(Doc.of("campaignNumber", 1), + Doc.of("name", "myCustomIndexName")); + assertEquals("myCustomIndexName", idx.getName()); + } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/IndexTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/IndexTest.java index 8128a956b..7cf0da79c 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/IndexTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/IndexTest.java @@ -22,6 +22,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -39,12 +40,12 @@ public class IndexTest extends MultiDriverTestBase { public void createIndexMapFromTest(Morphium morphium) { try (morphium) { List> idx = morphium.createIndexKeyMapFrom(new String[] {"-timer , -namne", "bla, fasel, blub"}); - assert(idx.size() == 2) : "Created indexes: " + idx.size(); - assert(idx.get(0).get("timer").equals(-1)); - assert(idx.get(0).get("namne").equals(-1)); - assert(idx.get(1).get("bla").equals(1)); - assert(idx.get(1).get("fasel").equals(1)); - assert(idx.get(1).get("blub").equals(1)); + assertTrue((idx.size() == 2), () -> String.valueOf("Created indexes: " + idx.size())); + assertTrue((idx.get(0).get("timer").equals(-1))); + assertTrue((idx.get(0).get("namne").equals(-1))); + assertTrue((idx.get(1).get("bla").equals(1))); + assertTrue((idx.get(1).get("fasel").equals(1))); + assertTrue((idx.get(1).get("blub").equals(1))); } } @@ -118,27 +119,27 @@ public void indexOnNewCollTest(Morphium morphium) throws Exception { if (key.get("_id") != null && key.get("_id").equals(1)) { foundId = true; - assert(i.getUnique() == null || !(Boolean) i.getUnique()); + assertTrue((i.getUnique() == null || !(Boolean) i.getUnique())); } else if (key.get("name") != null && key.get("name").equals(1) && key.get("timer") == null) { foundName = true; - assert(i.getUnique() == null || !(Boolean) i.getUnique()); + assertTrue((i.getUnique() == null || !(Boolean) i.getUnique())); } else if (key.get("timer") != null && key.get("timer").equals(-1) && key.get("name") == null) { foundTimer = true; - assert(i.getUnique() == null || !(Boolean) i.getUnique()); + assertTrue((i.getUnique() == null || !(Boolean) i.getUnique())); } else if (key.get("lst") != null) { foundLst = true; - assert(i.getUnique() == null || !(Boolean) i.getUnique()); + assertTrue((i.getUnique() == null || !(Boolean) i.getUnique())); } else if (key.get("timer") != null && key.get("timer").equals(-1) && key.get("name") != null && key.get("name").equals(-1)) { foundTimerName2 = true; - assert(i.getUnique() == null || !(Boolean) i.getUnique()); + assertTrue((i.getUnique() == null || !(Boolean) i.getUnique())); } else if (key.get("timer") != null && key.get("timer").equals(1) && key.get("name") != null && key.get("name").equals(-1)) { foundTimerName = true; - assert(i.getUnique() != null && (Boolean) i.getUnique()); + assertTrue((i.getUnique() != null && (Boolean) i.getUnique())); } } log.info("Found indices id:" + foundId + " timer: " + foundTimer + " TimerName: " + foundTimerName + " name: " + foundName + " TimerName2: " + foundTimerName2); - assert(foundId && foundTimer && foundTimerName && foundName && foundTimerName2 && foundLst); + assertTrue((foundId && foundTimer && foundTimerName && foundName && foundTimerName2 && foundLst)); } } @@ -171,22 +172,22 @@ public void ensureIndexHierarchyTest(Morphium morphium) throws Exception { if (key.get("_id") != null && key.get("_id").equals(1)) { foundId = true; - assert(i.getUnique() == null || !(Boolean) i.getUnique()); + assertTrue((i.getUnique() == null || !(Boolean) i.getUnique())); } else if (key.get("name") != null && key.get("something") == null && key.get("name").equals(1) && key.get("timer") == null) { foundName = true; - assert(i.getUnique() == null || !(Boolean) i.getUnique()); + assertTrue((i.getUnique() == null || !(Boolean) i.getUnique())); } else if (key.get("timer") != null && key.get("timer").equals(-1) && key.get("name") == null) { foundTimer = true; - assert(i.getUnique() == null || !(Boolean) i.getUnique()); + assertTrue((i.getUnique() == null || !(Boolean) i.getUnique())); } else if (key.get("lst") != null) { foundLst = true; - assert(i.getUnique() == null || !(Boolean) i.getUnique()); + assertTrue((i.getUnique() == null || !(Boolean) i.getUnique())); } else if (key.get("timer") != null && key.get("timer").equals(-1) && key.get("name") != null && key.get("name").equals(-1)) { foundTimerName2 = true; - assert(i.getUnique() == null || !(Boolean) i.getUnique()); + assertTrue((i.getUnique() == null || !(Boolean) i.getUnique())); } else if (key.get("timer") != null && key.get("timer").equals(1) && key.get("name") != null && key.get("name").equals(-1)) { foundTimerName = true; - assert(i.getUnique() == null || (Boolean) i.getUnique()); + assertTrue((i.getUnique() == null || (Boolean) i.getUnique())); } else if (key.get("something") != null && key.get("some_other") != null && key.get("something").equals(1) && key.get("some_other").equals(1)) { foundnew1 = true; } else if (key.get("name") != null && key.get("something") != null && key.get("name").equals(1) && key.get("something").equals(-1)) { @@ -196,7 +197,7 @@ public void ensureIndexHierarchyTest(Morphium morphium) throws Exception { log.info("Found indices id:" + foundId + " timer: " + foundTimer + " TimerName: " + foundTimerName + " name: " + foundName + " TimerName2: " + foundTimerName2 + " lst: " + foundLst + " SubIndex1: " + foundnew1 + " subIndex2: " + foundnew2); - assert(foundnew1 && foundnew2 && foundId && foundTimer && foundTimerName && foundName && foundTimerName2 && foundLst); + assertTrue((foundnew1 && foundnew2 && foundId && foundTimer && foundTimerName && foundName && foundTimerName2 && foundLst)); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/InterfacePolymorphismTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/InterfacePolymorphismTest.java index 838fb7270..4d9a67109 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/InterfacePolymorphismTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/InterfacePolymorphismTest.java @@ -13,6 +13,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Created with IntelliJ IDEA. @@ -32,7 +33,7 @@ public void polymorphTest(Morphium morphium) throws Exception { ifaceTestType.setPolyTest(new SubClass(11)); morphium.store(ifaceTestType); Thread.sleep(100); - assert (morphium.createQueryFor(IfaceTestType.class).countAll() == 1); + assertTrue((morphium.createQueryFor(IfaceTestType.class).countAll() == 1)); List lst = morphium.createQueryFor(IfaceTestType.class).asList(); for (IfaceTestType tst : lst) { log.info("Class " + tst.getClass().toString()); diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/IteratorTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/IteratorTest.java index d9f521492..8edc85560 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/IteratorTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/IteratorTest.java @@ -179,7 +179,7 @@ public void run() { Thread.sleep(200); } - assert(count.get() == totals) : "Count wrong, " + count.get() + " should be " + totals; + assertTrue((count.get() == totals), () -> String.valueOf("Count wrong, " + count.get() + " should be " + totals)); } } } @@ -190,12 +190,12 @@ public void emptyResultIteratorTest(Morphium morphium) { try (morphium) { for (UncachedObject uc : morphium.createQueryFor(UncachedObject.class).asIterable(1000)) { //noinspection ConstantConditions - assert(false); + assertTrue((false)); } for (UncachedObject uc : morphium.createQueryFor(UncachedObject.class).sort("-counter").asIterable(1000)) { //noinspection ConstantConditions - assert(false); + assertTrue((false)); } } } @@ -219,7 +219,7 @@ public void parallelIteratorAccessTest(Morphium morphium) throws Exception { for (MorphiumIterator it : toTest) { for (UncachedObject uc : it) { - assert(it.getCursor() == uc.getCounter()); + assertTrue((it.getCursor() == uc.getCounter())); if (it.getCursor() % 2500 == 0) { log.info("Thread " + myNum + " read " + it.getCursor() + "/" + count); @@ -264,14 +264,14 @@ public void doubleIteratorTest(Morphium morphium) { for (UncachedObject u : it) { Query other = morphium.createQueryFor(CachedObject.class).f("counter").gt(u.getCounter() % 100).f("counter").lt(u.getCounter() % 100 + 10).sort("counter"); MorphiumIterator otherIt = other.asIterable(); - assert(it.getCursor() == u.getCounter()); + assertTrue((it.getCursor() == u.getCounter())); for (CachedObject co : otherIt) { // log.info("iterating otherIt "+co.getCounter()); // Thread.sleep(200); assertNotNull(co.getValue()); ; - assert(co.getCounter() > u.getCounter() % 100 && co.getCounter() < u.getCounter() % 100 + 10); + assertTrue((co.getCounter() > u.getCounter() % 100 && co.getCounter() < u.getCounter() % 100 + 10)); } if (it.getCursor() % 100 == 0) { @@ -387,11 +387,11 @@ public void iteratorByIdTest(Morphium morphium) throws Exception { while (it.hasNext()) { u = it.next(); log.info("Object: " + u.getCounter()); - assert(u.getCounter() == read) : "Expected counter " + read + " but got " + u.getCounter(); // 0-based counters + assertTrue((u.getCounter() == read), String.valueOf("Expected counter " + read + " but got " + u.getCounter())); // 0-based counters read++; } - assert(read == 10000) : "Count wrong: " + read; // Should have read 10000 objects + assertTrue((read == 10000), String.valueOf("Count wrong: " + read)); // Should have read 10000 objects log.info("Took " + (System.currentTimeMillis() - start) + " ms"); } } @@ -424,7 +424,7 @@ public void iteratorRepeatTest(Morphium morphium) { } } - assert(!error); + assertTrue((!error)); log.info("Took " + (System.currentTimeMillis() - start) + " ms"); } } @@ -442,9 +442,9 @@ public void iteratorBoundaryTest(Morphium morphium) throws Exception { for (final MorphiumIterator it : toTest) { long start = System.currentTimeMillis(); // MorphiumIterator it = qu.asIterable(3); - assert(it.hasNext()); + assertTrue((it.hasNext())); UncachedObject u = it.next(); - assert(u.getCounter() == 0); // 0-based counters + assertTrue((u.getCounter() == 0)); // 0-based counters log.info("Got first one: " + u.getCounter() + " / " + u.getStrValue()); u = new UncachedObject(); u.setCounter(1800); @@ -457,7 +457,7 @@ public void iteratorBoundaryTest(Morphium morphium) throws Exception { log.info("Object: " + u.getCounter() + "/" + u.getStrValue()); } - assert(u.getCounter() == 16); // 0-based counters: 0-16 for 17 objects + assertTrue((u.getCounter() == 16)); // 0-based counters: 0-16 for 17 objects //cannot check buffersize anymore log.info("Took " + (System.currentTimeMillis() - start) + " ms"); } @@ -484,7 +484,7 @@ public void iteratorLimitTest(Morphium morphium) throws Exception { it.next(); } - assert(count == 10) : "Count wrong: " + count; + assertTrue((count == 10), String.valueOf("Count wrong: " + count)); log.info("Took " + (System.currentTimeMillis() - start) + " ms"); } } @@ -556,7 +556,7 @@ public void iterableSkipsTest(Morphium morphium) { log.info("Skipping 15 elements"); u = it.next(); log.info("After skip, counter: " + u.getCounter()); - assert(u.getCounter() == 24) : "Value is " + u.getCounter(); // Skip 15 objects (9-23), next() returns 24 + assertTrue((u.getCounter() == 24), String.valueOf("Value is " + u.getCounter())); // Skip 15 objects (9-23), next() returns 24 } if (u.getCounter() == 9 && !back) { @@ -565,7 +565,7 @@ public void iterableSkipsTest(Morphium morphium) { back = true; u = it.next(); log.info("After skip, counter: " + u.getCounter()); - assert(u.getCounter() == 6); + assertTrue((u.getCounter() == 6)); } } @@ -622,12 +622,12 @@ public void multithreaddedIteratorTest(Morphium morphium) throws Exception { while (it.hasNext()) { UncachedObject uc = it.next(); // 0-based counters: counter value equals position index, cursor is 1-based count of read objects - assert(uc.getCounter() == it.getCursor() - 1) : "Counter " + uc.getCounter() + " != cursor-1 " + (it.getCursor() - 1); - assert(uc.getCounter() == cnt) : "Counter " + uc.getCounter() + " != cnt " + cnt; + assertTrue((uc.getCounter() == it.getCursor() - 1), () -> String.valueOf("Counter " + uc.getCounter() + " != cursor-1 " + (it.getCursor() - 1))); + assertTrue((uc.getCounter() == cnt), String.valueOf("Counter " + uc.getCounter() + " != cnt " + cnt)); cnt++; } - assert(cnt == query.countAll()); + assertTrue((cnt == query.countAll())); } } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/JCacheTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/JCacheTest.java index 2825cb02d..a4830f7fd 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/JCacheTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/JCacheTest.java @@ -23,6 +23,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -75,7 +76,7 @@ public void getProviderTest(Morphium morphium) throws Exception { e.destroyCache("Testcache"); e.unwrap(e.getClass()); - assert (!e.isClosed()); + assertTrue((!e.isClosed())); lst.add(e); @@ -135,7 +136,7 @@ private void cacheTest(Morphium morphium, MorphiumCache cache) throws Exception Map sizes = cache.getSizes(); for (String k : sizes.keySet()) { log.info("Key " + k + " size: " + sizes.get(k)); - assert (sizes.get(k) > 0); + assertTrue((sizes.get(k) > 0)); } Map stats = morphium.getStatistics(); for (String k : stats.keySet()) { diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/LastAccessTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/LastAccessTest.java index b8eba60c7..d000f0abd 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/LastAccessTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/LastAccessTest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -28,7 +29,7 @@ public void createdTest(Morphium morphium) throws Exception { TstObjLA tst = new TstObjLA(); tst.setValue("A value"); morphium.store(tst); - assert(tst.getCreationTime() > 0) : "No creation time set?!?!?!"; + assertTrue((tst.getCreationTime() > 0), "No creation time set?!?!?!"); long creationTime = tst.getCreationTime(); // Wait until we can verify the object exists and enough time has passed @@ -38,10 +39,10 @@ public void createdTest(Morphium morphium) throws Exception { tst.setValue("Annother value"); morphium.store(tst); - assert(tst.getLastChange() > 0) : "No last change set?"; - assert(tst.getLastChange() > creationTime) : "No last change set?"; + assertTrue((tst.getLastChange() > 0), "No last change set?"); + assertTrue((tst.getLastChange() > creationTime), "No last change set?"); long lastChange = tst.getLastChange(); - assert(tst.getCreationTime() == creationTime) : "Creation time change? was: " + creationTime + " is " + tst.getCreationTime(); + assertTrue((tst.getCreationTime() == creationTime), String.valueOf("Creation time change? was: " + creationTime + " is " + tst.getCreationTime())); Query q = morphium.createQueryFor(TstObjLA.class); // Wait for lastAccess to be set (happens on read) @@ -53,12 +54,12 @@ public void createdTest(Morphium morphium) throws Exception { }); tst = q.get(); - assert(tst.getLastAccess() > 0) : "No last_access set?"; + assertTrue((tst.getLastAccess() > 0), "No last_access set?"); long lastAccess = tst.getLastAccess(); - assert(tst.getCreationTime() == creationTime) : "Creation time change?"; - assert(tst.getLastAccess() != tst.getCreationTime()) : "Last access == creation time"; + assertTrue((tst.getCreationTime() == creationTime), "Creation time change?"); + assertTrue((tst.getLastAccess() != tst.getCreationTime()), "Last access == creation time"); tst = q.asList().get(0); - assert(tst.getLastAccess() > 0) : "No last_access set?"; + assertTrue((tst.getLastAccess() > 0), "No last_access set?"); Query q2 = morphium.createQueryFor(TstObjLA.class); // Wait for lastAccess to change again @@ -70,11 +71,11 @@ public void createdTest(Morphium morphium) throws Exception { }); tst = q2.get(); - assert(tst.getLastAccess() != lastAccess) : "Last Access did not change?"; + assertTrue((tst.getLastAccess() != lastAccess), "Last Access did not change?"); // lastChange should not have changed since we only read, didn't store // Allow small tolerance for async operations - assert(tst.getLastChange() == lastChange) : "Last Change changed unexpectedly from " + lastChange + " to " + tst.getLastChange(); - assert(tst.getCreationTime() == creationTime) : "Creation time changed from " + creationTime + " to " + tst.getCreationTime(); + assertTrue((tst.getLastChange() == lastChange), String.valueOf("Last Change changed unexpectedly from " + lastChange + " to " + tst.getLastChange())); + assertTrue((tst.getCreationTime() == creationTime), String.valueOf("Creation time changed from " + creationTime + " to " + tst.getCreationTime())); } @ParameterizedTest @@ -88,9 +89,9 @@ public void createOnUpsert(Morphium morphium) throws Exception { () -> morphium.createQueryFor(TstObjLA.class).countAll() > 0); TstObjLA tst = morphium.createQueryFor(TstObjLA.class).get(); - assert(tst.getIntValue() == 12); - assert(tst.getValue().equals("a test")); - assert(tst.getCreationTime() != 0); + assertTrue((tst.getIntValue() == 12)); + assertTrue((tst.getValue().equals("a test"))); + assertTrue((tst.getCreationTime() != 0)); } @ParameterizedTest @@ -106,7 +107,7 @@ public void createdTestStringId(Morphium morphium) throws Exception { tst.setId("test1"); tst.setValue("A value"); morphium.store(tst); - assert(tst.getCreationTime() > 0) : "No creation time set?!?!?!"; + assertTrue((tst.getCreationTime() > 0), "No creation time set?!?!?!"); long creationTime = tst.getCreationTime(); // Wait until we can verify the object exists and enough time has passed @@ -116,10 +117,10 @@ public void createdTestStringId(Morphium morphium) throws Exception { tst.setValue("Annother value"); morphium.store(tst); - assert(tst.getLastChange() > 0) : "No last change set?"; - assert(tst.getLastChange() > creationTime) : "No last change set?"; + assertTrue((tst.getLastChange() > 0), "No last change set?"); + assertTrue((tst.getLastChange() > creationTime), "No last change set?"); long lastChange = tst.getLastChange(); - assert(tst.getCreationTime() == creationTime) : "Creation time change?"; + assertTrue((tst.getCreationTime() == creationTime), "Creation time change?"); Query q = morphium.createQueryFor(TstObjAutoValuesStringId.class); // Wait for lastAccess to be set (happens on read) @@ -131,12 +132,12 @@ public void createdTestStringId(Morphium morphium) throws Exception { }); tst = q.get(); - assert(tst.getLastAccess() > 0) : "No last_access set?"; + assertTrue((tst.getLastAccess() > 0), "No last_access set?"); long lastAccess = tst.getLastAccess(); - assert(tst.getCreationTime() == creationTime) : "Creation time change?"; - assert(tst.getLastAccess() != tst.getCreationTime()) : "Last access == creation time"; + assertTrue((tst.getCreationTime() == creationTime), "Creation time change?"); + assertTrue((tst.getLastAccess() != tst.getCreationTime()), "Last access == creation time"); tst = q.asList().get(0); - assert(tst.getLastAccess() > 0) : "No last_access set?"; + assertTrue((tst.getLastAccess() > 0), "No last_access set?"); Query q2 = morphium.createQueryFor(TstObjAutoValuesStringId.class); // Wait for lastAccess to change again @@ -148,9 +149,9 @@ public void createdTestStringId(Morphium morphium) throws Exception { }); tst = q2.get(); - assert(tst.getLastAccess() != lastAccess) : "Last Access did not change?"; - assert(tst.getLastChange() == lastChange); - assert(tst.getCreationTime() == creationTime); + assertTrue((tst.getLastAccess() != lastAccess), "Last Access did not change?"); + assertTrue((tst.getLastChange() == lastChange)); + assertTrue((tst.getCreationTime() == creationTime)); } @ParameterizedTest @@ -166,8 +167,8 @@ public void testLastAccessInc(Morphium morphium) throws Exception { () -> morphium.findById(TstObjLA.class, laId) != null); morphium.reread(la); - assert(la.creationTime != 0); - assert(la.lastChange != 0); + assertTrue((la.creationTime != 0)); + assertTrue((la.lastChange != 0)); la.setValue("new Value"); morphium.store(la); @@ -181,7 +182,7 @@ public void testLastAccessInc(Morphium morphium) throws Exception { morphium.reread(la); long lc = la.getLastChange(); - assert(la.getCreationTime() != la.getLastChange()); + assertTrue((la.getCreationTime() != la.getLastChange())); morphium.setInEntity(la, "value", "set"); // Wait for setInEntity to be visible on replica sets final long lcBeforeSet = lc; @@ -191,7 +192,7 @@ public void testLastAccessInc(Morphium morphium) throws Exception { return found != null && found.getLastChange() != lcBeforeSet; }); morphium.reread(la); - assert(lc != la.getLastChange()); + assertTrue((lc != la.getLastChange())); lc = la.getLastChange(); la.setIntValue(41); Thread.sleep(50); // Small delay to ensure timestamp difference @@ -206,7 +207,7 @@ public void testLastAccessInc(Morphium morphium) throws Exception { }); morphium.reread(la); - assert(lc != la.getLastChange()); + assertTrue((lc != la.getLastChange())); lc = la.getLastChange(); morphium.inc(la, "int_value", 1); @@ -219,8 +220,8 @@ public void testLastAccessInc(Morphium morphium) throws Exception { }); morphium.reread(la); - assert(la.getIntValue() == 42); - assert(lc != la.getLastChange()); + assertTrue((la.getIntValue() == 42)); + assertTrue((lc != la.getLastChange())); // Now using ID query lc = la.getLastChange(); @@ -236,8 +237,8 @@ public void testLastAccessInc(Morphium morphium) throws Exception { }); morphium.reread(la); - assert(la.getIntValue() == 1); - assert(lc != la.getLastChange()); + assertTrue((la.getIntValue() == 1)); + assertTrue((lc != la.getLastChange())); lc = la.getLastChange(); Thread.sleep(50); // Ensure timestamp difference from previous operation morphium.inc(q, "int_value", 41); @@ -250,8 +251,8 @@ public void testLastAccessInc(Morphium morphium) throws Exception { }); morphium.reread(la); - assert(la.getIntValue() == 42); - assert(lc != la.getLastChange()); + assertTrue((la.getIntValue() == 42)); + assertTrue((lc != la.getLastChange())); } @Entity diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/LazyLoadingTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/LazyLoadingTest.java index 4f9daa13f..ac283849a 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/LazyLoadingTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/LazyLoadingTest.java @@ -67,12 +67,12 @@ public void deRefTest(Morphium morphium) throws Exception { Object id = morphium.getId(lzRead); assertNotNull(id); ; - assert (lzRead.getLazyUncached().getCounter() == 15); - assert (lzRead.getLazyUncached().getStrValue().equals("A uncached value")); + assertTrue((lzRead.getLazyUncached().getCounter() == 15)); + assertTrue((lzRead.getLazyUncached().getStrValue().equals("A uncached value"))); co = lzRead.getLazyCached(); Thread.sleep(1000); id = morphium.getId(co); - assert (co.getCounter() == 22) : "Counter wrong.." + co.getCounter(); + assertTrue((co.getCounter() == 22), String.valueOf("Counter wrong.." + co.getCounter())); assertNotNull(id); ; @@ -124,7 +124,7 @@ public void lazyLoadingTest(Morphium morphium) { assertNotNull(lzRead, "Not found????"); log.info("LZRead: " + lzRead.getClass().getName()); - assert (!(lzRead instanceof MorphiumProxyMarker)) : "Lazy loader in Root-Object?"; + assertTrue((!(lzRead instanceof MorphiumProxyMarker)), "Lazy loader in Root-Object?"); Double rd = morphium.getStatistics().get(StatisticKeys.READS.name()); if (rd == null) { rd = 0.0; @@ -133,11 +133,11 @@ public void lazyLoadingTest(Morphium morphium) { int cnt = lzRead.getLazyUncached().getCounter(); log.info("uncached: " + lzRead.getLazyUncached().getClass().getName()); - assert (lzRead.getLazyUncached() instanceof MorphiumProxyMarker) : "Not lazy loader?"; + assertTrue((lzRead.getLazyUncached() instanceof MorphiumProxyMarker), "Not lazy loader?"); - assert (cnt == o.getCounter()) : "Counter not equal"; + assertTrue((cnt == o.getCounter()), "Counter not equal"); double rd2 = morphium.getStatistics().get(StatisticKeys.READS.name()); - assert (rd2 > rd) : "No read?"; + assertTrue((rd2 > rd), "No read?"); if (morphium.getDriver().getName().equals(InMemoryDriver.driverName)) { log.info("Cannot check for caching, inMemoryDriver enabled"); @@ -145,16 +145,16 @@ public void lazyLoadingTest(Morphium morphium) { rd = morphium.getStatistics().get(StatisticKeys.READS.name()); double crd = morphium.getStatistics().get(StatisticKeys.CACHE_ENTRIES.name()); cnt = lzRead.getLazyCached().getCounter(); - assert (cnt == co.getCounter()) : "Counter (cached) not equal"; + assertTrue((cnt == co.getCounter()), "Counter (cached) not equal"); rd2 = morphium.getStatistics().get(StatisticKeys.READS.name()); - assert (rd2 > rd) : "No read?"; + assertTrue((rd2 > rd), "No read?"); log.info("Cache Entries:" + morphium.getStatistics().get(StatisticKeys.CACHE_ENTRIES.name())); assertTrue (morphium.getStatistics().get(StatisticKeys.CACHE_ENTRIES.name()) > crd, "not cached"); } - assert (lzRead.getLazyLst().size() == lz.getLazyLst().size()) : "List sizes differ?!?!"; + assertTrue((lzRead.getLazyLst().size() == lz.getLazyLst().size()), "List sizes differ?!?!"); for (UncachedObject uc : lzRead.getLazyLst()) { - assert (uc instanceof MorphiumProxyMarker) : "Lazy list not lazy?"; + assertTrue((uc instanceof MorphiumProxyMarker), "Lazy list not lazy?"); } @@ -276,14 +276,14 @@ public void testLazyRef(Morphium morphium) throws Exception { Thread.sleep(200); SimpleEntity s1Fetched = m.createQueryFor(SimpleEntity.class).f("value").eq(1).get(); - assert (s1Fetched.value == 1); + assertTrue((s1Fetched.value == 1)); SimpleEntity s2Fetched = m.createQueryFor(SimpleEntity.class).f("value").eq(2).get(); - assert (s2Fetched.value == 2); + assertTrue((s2Fetched.value == 2)); SimpleEntity s3Fetched = m.createQueryFor(SimpleEntity.class).f("value").eq(3).get(); - assert (s3Fetched.value == 3); - assert (s2Fetched.getRef().getValue() == 1); + assertTrue((s3Fetched.value == 3)); + assertTrue((s2Fetched.getRef().getValue() == 1)); System.out.println(s2Fetched.lazyRef.value); - assert (s2Fetched.getLazyRef().getValue() == 3); + assertTrue((s2Fetched.getLazyRef().getValue() == 3)); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ListOfListTests.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ListOfListTests.java index 7593b4193..4abe2bcc0 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ListOfListTests.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ListOfListTests.java @@ -12,6 +12,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -48,9 +49,9 @@ public void storeListOfLists(Morphium morphium) { morphium.store(l); LoLType l2 = morphium.createQueryFor(LoLType.class).f("id").eq(l.id).get(); - assert (l2.lst.size() == l.lst.size()) : "Error in list sizes"; - assert (l2.lst.get(0).size() == l.lst.get(0).size()) : "error in sublist sizes"; - assert (l2.lst.get(1).get(0).equals(l.lst.get(1).get(0))) : "error in sublist values"; + assertTrue((l2.lst.size() == l.lst.size()), "Error in list sizes"); + assertTrue((l2.lst.get(0).size() == l.lst.get(0).size()), "error in sublist sizes"); + assertTrue((l2.lst.get(1).get(0).equals(l.lst.get(1).get(0))), "error in sublist values"); } @@ -68,7 +69,7 @@ public void jsonListTest(Morphium morphium) throws Exception { System.out.println(l.getStringList().get(0)); List lst = l.getUcLstList().get(0); u = lst.get(1); - assert (u.getCounter() == 1); + assertTrue((u.getCounter() == 1)); System.out.println("Done"); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ListTests.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ListTests.java index f65b674e8..a13d06c1e 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ListTests.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ListTests.java @@ -22,6 +22,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -57,7 +58,7 @@ public void listStoringTest(Morphium morphium) throws Exception { morphium.storeList(lst); Thread.sleep(200); long count = morphium.createQueryFor(UncachedObject.class, "UCTest").countAll(); - assert(count == 100) : "Count wrong " + count; + assertTrue((count == 100), () -> String.valueOf("Count wrong " + count)); } @ParameterizedTest @@ -163,9 +164,9 @@ public void nullValueListTest(Morphium morphium) throws InterruptedException { Query q = morphium.createQueryFor(ListContainer.class).f("id").eq(lst.getId()); q.setReadPreferenceLevel(ReadPreferenceLevel.PRIMARY); ListContainer lst2 = q.get(); - assert(lst2.getStringList().get(count) == null); - assert(lst2.getRefList().get(count) == null); - assert(lst2.getEmbeddedObjectList().get(count) == null); + assertTrue((lst2.getStringList().get(count) == null)); + assertTrue((lst2.getRefList().get(count) == null)); + assertTrue((lst2.getEmbeddedObjectList().get(count) == null)); } @@ -184,7 +185,7 @@ public void singleEntryListTest(Morphium morphium) throws Exception { lst.get(0).setCounter(999); morphium.storeList(lst); Thread.sleep(100); - assert(morphium.createQueryFor(UncachedObject.class).asList().get(0).getCounter() == 999); + assertTrue((morphium.createQueryFor(UncachedObject.class).asList().get(0).getCounter() == 999)); } @ParameterizedTest @@ -241,20 +242,20 @@ public void testHybridList(Morphium morphium) throws InterruptedException { TestUtils.waitForConditionToBecomeTrue(15000, "Object not queryable", () -> morphium.findById(MyListContainer.class, expectedId) != null); MyListContainer mc2 = morphium.findById(MyListContainer.class, expectedId); - assert(mc2.id.equals(mc.id)); - assert(mc2.objectList.size() == mc.objectList.size()); - assert(mc2.objectList.get(0) instanceof UncachedObject); - assert(mc2.objectList.get(1) instanceof EmbeddedObject); - assert(mc2.objectList.get(2) instanceof ExtendedEmbeddedObject); - assert(((UncachedObject) mc2.objectList.get(0)).getStrValue().equals("val")); - assert(((UncachedObject) mc2.objectList.get(0)).getCounter() == 42); - assert(((EmbeddedObject) mc2.objectList.get(1)).getValue().equals("Embedded")); - assert(((EmbeddedObject) mc2.objectList.get(1)).getName().equals("Fred")); - assert(((EmbeddedObject) mc2.objectList.get(1)).getTest() != 0); - assert(((ExtendedEmbeddedObject) mc2.objectList.get(2)).getName().equals("testName")); - assert(((ExtendedEmbeddedObject) mc2.objectList.get(2)).getAdditionalValue().equals("additionalValue")); - assert(((ExtendedEmbeddedObject) mc2.objectList.get(2)).getTest() == 4711); - assert(((ExtendedEmbeddedObject) mc2.objectList.get(2)).getValue().equals("value")); + assertTrue((mc2.id.equals(mc.id))); + assertTrue((mc2.objectList.size() == mc.objectList.size())); + assertTrue((mc2.objectList.get(0) instanceof UncachedObject)); + assertTrue((mc2.objectList.get(1) instanceof EmbeddedObject)); + assertTrue((mc2.objectList.get(2) instanceof ExtendedEmbeddedObject)); + assertTrue((((UncachedObject) mc2.objectList.get(0)).getStrValue().equals("val"))); + assertTrue((((UncachedObject) mc2.objectList.get(0)).getCounter() == 42)); + assertTrue((((EmbeddedObject) mc2.objectList.get(1)).getValue().equals("Embedded"))); + assertTrue((((EmbeddedObject) mc2.objectList.get(1)).getName().equals("Fred"))); + assertTrue((((EmbeddedObject) mc2.objectList.get(1)).getTest() != 0)); + assertTrue((((ExtendedEmbeddedObject) mc2.objectList.get(2)).getName().equals("testName"))); + assertTrue((((ExtendedEmbeddedObject) mc2.objectList.get(2)).getAdditionalValue().equals("additionalValue"))); + assertTrue((((ExtendedEmbeddedObject) mc2.objectList.get(2)).getTest() == 4711)); + assertTrue((((ExtendedEmbeddedObject) mc2.objectList.get(2)).getValue().equals("value"))); } @ParameterizedTest @@ -273,14 +274,14 @@ public void idListTest(Morphium morphium) throws Exception { assertNotNull(ilst.id); ; MyIdListContainer ilst2 = morphium.createQueryFor(MyIdListContainer.class).get(); - assert(ilst2.idList.size() == ilst.idList.size()); - assert(ilst2.idList.get(0).equals(ilst.idList.get(0))); + assertTrue((ilst2.idList.size() == ilst.idList.size())); + assertTrue((ilst2.idList.get(0).equals(ilst.idList.get(0)))); ilst2.idList.add(new MorphiumId()); ilst2.number = 234; morphium.store(ilst2); Thread.sleep(100); - assert(ilst2.idList.get(0) instanceof MorphiumId); - assert(ilst2.idList.get(0).equals(ilst.idList.get(0))); + assertTrue((ilst2.idList.get(0) instanceof MorphiumId)); + assertTrue((ilst2.idList.get(0).equals(ilst.idList.get(0)))); } @ParameterizedTest @@ -296,12 +297,12 @@ public void unGenericListTest(Morphium morphium) throws Exception { morphium.store(c); Thread.sleep(100); morphium.reread(c); - assert(c.name.equals("test")); - assert(c.number == 44); - assert(c.aList.size() == 3); - assert(c.aList.get(0) instanceof String); - assert(c.aList.get(1) instanceof Integer); - assert(c.aList.get(2) instanceof UncachedObject); + assertTrue((c.name.equals("test"))); + assertTrue((c.number == 44)); + assertTrue((c.aList.size() == 3)); + assertTrue((c.aList.get(0) instanceof String)); + assertTrue((c.aList.get(1) instanceof Integer)); + assertTrue((c.aList.get(2) instanceof UncachedObject)); } @Entity(collectionName = "UCTest") diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MapListTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MapListTest.java index 162f056a3..7868ec3aa 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MapListTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MapListTest.java @@ -59,10 +59,11 @@ public void mapListTest(Morphium morphium) throws InterruptedException { listMap.put("zweihundert", lst); o.setMapListValue(listMap); morphium.store(o); - Thread.sleep(100); + TestUtils.waitForConditionToBecomeTrue(5000, "Object not found after store", + () -> morphium.findById(MapListObject.class, o.getId()) != null); MapListObject ml = morphium.findById(MapListObject.class, o.getId()); - assert(ml.getMapListValue().get("eins-fuenf-drei").size() == 3); - assert(ml.getMapListValue().get("zweihundert").size() == 4); + assertTrue(ml.getMapListValue().get("eins-fuenf-drei").size() == 3); + assertTrue(ml.getMapListValue().get("zweihundert").size() == 4); } @ParameterizedTest @@ -108,16 +109,17 @@ public void mapListEmbTest(Morphium morphium) throws InterruptedException { map1.put("2nd", objLst); o.setMap1(map1); morphium.store(o); - Thread.sleep(100); + TestUtils.waitForConditionToBecomeTrue(5000, "Object not found after store", + () -> morphium.findById(CMapListObject.class, o.getId()) != null); CMapListObject ml = morphium.findById(CMapListObject.class, o.getId()); assertNotNull(ml, "Not Found?!?!?!?"); - assert(ml.getMapListValue().get("eins-fuenf-drei").size() == 3); - assert(ml.getMapListValue().get("zweihundert").size() == 4); + assertTrue(ml.getMapListValue().get("eins-fuenf-drei").size() == 3); + assertTrue(ml.getMapListValue().get("zweihundert").size() == 4); assertNotNull(ml.getMapListValue().get("zweihundert").get(0)); ; assertNotNull(ml.getMap1().get("2nd").get(0).getTest()); ; - assert(ml.getMap2().get("test").getTest().equals("val")); + assertTrue(ml.getMap2().get("test").getTest().equals("val")); } @ParameterizedTest @@ -137,11 +139,12 @@ public void testComplexList(Morphium morphium) throws InterruptedException { lst.add(strMap); o.setMap7(lst); morphium.store(o); - Thread.sleep(100); + TestUtils.waitForConditionToBecomeTrue(5000, "Object not found after store", + () -> morphium.findById(CMapListObject.class, o.getId()) != null); CMapListObject ml = morphium.findById(CMapListObject.class, o.getId()); assertNotNull(ml, "Not Found?!?!?!?"); - assert(ml.getMap7().get(0).get("tst1").equals("bla")); - assert(ml.getMap7().get(1).get("tst2-2").equals("blub")); + assertTrue(ml.getMap7().get(0).get("tst1").equals("bla")); + assertTrue(ml.getMap7().get(1).get("tst2-2").equals("blub")); } @ParameterizedTest @@ -161,10 +164,11 @@ public void testMapOfListsString(Morphium morphium) throws InterruptedException m.put("m2", lst); o.setMap3(m); morphium.store(o); - Thread.sleep(100); + TestUtils.waitForConditionToBecomeTrue(5000, "Object not found after store", + () -> morphium.findById(CMapListObject.class, o.getId()) != null); CMapListObject ml = morphium.findById(CMapListObject.class, o.getId()); - assert(ml.getMap3().get("m1").get(1).equals("fasel")); - assert(ml.getMap3().get("m2").get(2).equals("grin")); + assertTrue(ml.getMap3().get("m1").get(1).equals("fasel")); + assertTrue(ml.getMap3().get("m2").get(2).equals("grin")); } @ParameterizedTest @@ -184,14 +188,14 @@ public void testMapOfListsEmb(Morphium morphium) throws InterruptedException { m.put("m2", lst); o.setMap4(m); morphium.store(o); - Thread.sleep(100); Query q = morphium.createQueryFor(CMapListObject.class).f("id").eq(o.getId()); q.setReadPreferenceLevel(ReadPreferenceLevel.PRIMARY); + TestUtils.waitForConditionToBecomeTrue(5000, "Object not found after store", () -> q.get() != null); CMapListObject ml = q.get(); - assert(ml.getMap4().get("m1").get(1).getTest().equals("fasel")); - assert(ml.getMap4().get("m1").get(1).getValue() == 42); - assert(ml.getMap4().get("m2").get(2).getTest().equals("grin")); - assert(ml.getMap4().get("m2").get(2).getValue() == 7331); + assertTrue(ml.getMap4().get("m1").get(1).getTest().equals("fasel")); + assertTrue(ml.getMap4().get("m1").get(1).getValue() == 42); + assertTrue(ml.getMap4().get("m2").get(2).getTest().equals("grin")); + assertTrue(ml.getMap4().get("m2").get(2).getValue() == 7331); } @ParameterizedTest @@ -213,10 +217,11 @@ public void testMapOfMaps(Morphium morphium) throws InterruptedException { m.put("translate", mVal); o.setMap5(m); morphium.store(o); - Thread.sleep(100); + TestUtils.waitForConditionToBecomeTrue(5000, "Object not found after store", + () -> morphium.findById(CMapListObject.class, o.getId()) != null); CMapListObject ml = morphium.findById(CMapListObject.class, o.getId()); - assert(ml.getMap5().get("test").get("bla").equals("fasel")); - assert(ml.getMap5().get("translate").get("foo").equals("bla")); + assertTrue(ml.getMap5().get("test").get("bla").equals("fasel")); + assertTrue(ml.getMap5().get("translate").get("foo").equals("bla")); } @ParameterizedTest @@ -238,10 +243,11 @@ public void testMapOfMapEmbObj(Morphium morphium) throws InterruptedException { m.put("translate", mVal); o.setMap5a(m); morphium.store(o); - Thread.sleep(100); + TestUtils.waitForConditionToBecomeTrue(5000, "Object not found after store", + () -> morphium.findById(CMapListObject.class, o.getId()) != null); CMapListObject ml = morphium.findById(CMapListObject.class, o.getId()); - assert(ml.getMap5a().get("test").get("bla").getTest().equals("fasel")); - assert(ml.getMap5a().get("translate").get("foo").getTest().equals("bla")); + assertTrue(ml.getMap5a().get("test").get("bla").getTest().equals("fasel")); + assertTrue(ml.getMap5a().get("translate").get("foo").getTest().equals("bla")); } @ParameterizedTest @@ -279,9 +285,10 @@ public void testListOfListOfMap(Morphium morphium) throws InterruptedException lst.add(l2); o.setMap7a(lst); morphium.store(o); - Thread.sleep(100); + TestUtils.waitForConditionToBecomeTrue(5000, "Object not found after store", + () -> morphium.findById(CMapListObject.class, o.getId()) != null); CMapListObject ml = morphium.findById(CMapListObject.class, o.getId()); - assert(ml.getMap7a().get(1).get(0).get("k15").equals("v1")); + assertTrue(ml.getMap7a().get(1).get(0).get("k15").equals("v1")); } @ParameterizedTest @@ -320,10 +327,11 @@ public void testMapListMapEmb(Morphium morphium) throws InterruptedException { map.put("list1", lst); o.setMap6a(map); morphium.store(o); - Thread.sleep(100); + TestUtils.waitForConditionToBecomeTrue(5000, "Object not found after store", + () -> morphium.findById(CMapListObject.class, o.getId()) != null); CMapListObject ml = morphium.findById(CMapListObject.class, o.getId()); //Map->List->Map->EmbObj - assert(ml.getMap6a().get("list1").get(0).get("map1-v2").getTest().equals("test2")); + assertTrue(ml.getMap6a().get("list1").get(0).get("map1-v2").getTest().equals("test2")); } @ParameterizedTest @@ -332,7 +340,9 @@ public void complexMapTest(Morphium morphium) throws InterruptedException { MapListObject o = new MapListObject(); o.setMapValue(UtilsMap.of("Testvalue", (Object) UtilsMap.of("$lte", "@123"))); morphium.save(o); - Thread.sleep(100); + MapListObject saved = o; + TestUtils.waitForConditionToBecomeTrue(5000, "Object not found after save", + () -> morphium.reread(saved) != null); o = morphium.reread(o); assertTrue(o.getMapValue().containsKey("Testvalue")); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MapReduceTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MapReduceTest.java index 116611023..0e10edcc5 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MapReduceTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MapReduceTest.java @@ -12,6 +12,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Created by stephan on 28.07.16. @@ -48,11 +49,11 @@ public void doSimpleMRTest(Morphium m) throws Exception { even = true; } - assert(r.getCounter() > 0); + assertTrue((r.getCounter() > 0)); } - assert(odd); - assert(even); + assertTrue((odd)); + assertTrue((even)); } } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MapSubDocumentTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MapSubDocumentTest.java index a8a187f30..34d9c6bad 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MapSubDocumentTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MapSubDocumentTest.java @@ -14,6 +14,7 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("core") public class MapSubDocumentTest extends MultiDriverTestBase { @@ -30,11 +31,11 @@ public void testMapSubDocument(Morphium morphium) throws Exception { morphium.store(m); Thread.sleep(500); MapDoc d = morphium.findById(MapDoc.class, m.id); - assert(d.value.equals("Val")); + assertTrue((d.value.equals("Val"))); assertNotNull(d.mapValue); ; - assert(d.mapValue.get(42L).equals("life and universe and everything")); - assert(d.mapValue.get(54322321L).equals("test 2")); + assertTrue((d.mapValue.get(42L).equals("life and universe and everything"))); + assertTrue((d.mapValue.get(54322321L).equals("test 2"))); } //this test will fail with map keys that cannot easily be translated diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MassCacheTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MassCacheTest.java index bb54fb1fa..59959a22c 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MassCacheTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MassCacheTest.java @@ -21,6 +21,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * @author stephan @@ -106,7 +107,7 @@ public void run() { q.f("counter").eq(j + 1).f("value").eq("Writing thread " + i + " " + j); List lst = q.asList(); - assert (lst != null && !lst.isEmpty()) : "List is null - Thread " + i + " Element " + (j + 1) + " not found"; + assertTrue((lst != null && !lst.isEmpty()), String.valueOf("List is null - Thread " + i + " Element " + (j + 1) + " not found")); } log.info(i + "" + "/" + WRITING_THREADS); @@ -234,8 +235,8 @@ public void disableCacheTest(Morphium morphium) { q.f("value").eq("Test " + i); List lst = q.asList(); assertNotNull(lst, "List is NULL????"); - assert (!lst.isEmpty()) : "Not found?!?!? Value: Test " + i; - assert (lst.get(0).getValue().equals("Test " + i)) : "Wrong value!"; + assertTrue((!lst.isEmpty()), String.valueOf("Not found?!?!? Value: Test " + i)); + assertTrue((lst.get(0).getValue().equals("Test " + i)), "Wrong value!"); log.info("found " + lst.size() + " elements for value: " + lst.get(0).getValue()); } @@ -243,8 +244,8 @@ public void disableCacheTest(Morphium morphium) { printStats(morphium); Map statistics = morphium.getStatistics(); - assert (statistics.get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") == null || statistics.get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") == 0); - assert (statistics.get("WRITES_CACHED") == 0); + assertTrue((statistics.get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") == null || statistics.get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") == 0)); + assertTrue((statistics.get("WRITES_CACHED") == 0)); morphium.getConfig().cacheSettings().setReadCacheEnabled(true); for (int j = 0; j < 3; j++) { for (int i = 0; i < NO_OBJECTS; i++) { @@ -252,17 +253,17 @@ public void disableCacheTest(Morphium morphium) { q.f("value").eq("Test " + i); List lst = q.asList(); assertNotNull(lst, "List is NULL????"); - assert (!lst.isEmpty()) : "Not found?!?!? Value: Test " + i; - assert (lst.get(0).getValue().equals("Test " + i)) : "Wrong value!"; + assertTrue((!lst.isEmpty()), String.valueOf("Not found?!?!? Value: Test " + i)); + assertTrue((lst.get(0).getValue().equals("Test " + i)), "Wrong value!"); log.info("found " + lst.size() + " elements for value: " + lst.get(0).getValue()); } } printStats(morphium); statistics = morphium.getStatistics(); - assert (statistics.get("CACHE_ENTRIES") != 0); - assert (statistics.get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") > 0); - assert (statistics.get("CHITS") != 0); + assertTrue((statistics.get("CACHE_ENTRIES") != 0)); + assertTrue((statistics.get("X-Entries for: resultCache|de.caluga.test.mongo.suite.data.CachedObject") > 0)); + assertTrue((statistics.get("CHITS") != 0)); } finally { morphium.getConfig().cacheSettings().setReadCacheEnabled(true); morphium.getConfig().cacheSettings().setBufferedWritesEnabled(true); @@ -297,8 +298,8 @@ public void cacheTest(Morphium morphium) throws Exception { q.f("value").eq("Test " + i); List lst = q.asList(); assertNotNull(lst, "List is NULL????"); - assert (!lst.isEmpty()) : "Not found?!?!? Value: Test " + i; - assert (lst.get(0).getValue().equals("Test " + i)) : "Wrong value!"; + assertTrue((!lst.isEmpty()), String.valueOf("Not found?!?!? Value: Test " + i)); + assertTrue((lst.get(0).getValue().equals("Test " + i)), "Wrong value!"); log.info("found " + lst.size() + " elements for value: " + lst.get(0).getValue()); } @@ -307,9 +308,9 @@ public void cacheTest(Morphium morphium) throws Exception { printStats(morphium); Map stats = morphium.getStatistics(); - assert (stats.get("CACHE_ENTRIES") >= 100); - assert (stats.get("CHITS") >= 200); - assert (stats.get("CHITSPERC") >= 40); + assertTrue((stats.get("CACHE_ENTRIES") >= 100)); + assertTrue((stats.get("CHITS") >= 200)); + assertTrue((stats.get("CHITSPERC") >= 40)); morphium.getCache().setDefaultCacheTime(CachedObject.class); morphium.getCache().clearCachefor(CachedObject.class); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MorphiumCursorTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MorphiumCursorTest.java index 664f534b6..ff9301457 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MorphiumCursorTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MorphiumCursorTest.java @@ -20,6 +20,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -75,7 +76,7 @@ public void cursorSortTest(Morphium morphium) throws Exception { lastv2 = u.v2; lastv1 = u.v1; } - assert (!error); + assertTrue((!error)); } @ParameterizedTest diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MorphiumTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MorphiumTest.java index 2f582ad69..b847190ab 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MorphiumTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/MorphiumTest.java @@ -15,6 +15,10 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + @Tag("core") public class MorphiumTest extends MultiDriverTestBase { @@ -22,10 +26,10 @@ public class MorphiumTest extends MultiDriverTestBase { @MethodSource("getMorphiumInstancesNoSingle") public void testListDatabases(Morphium morphium) throws Exception { createUncachedObjects(morphium, 1); - Thread.sleep(10); - assert (morphium.listDatabases().size() != 0); - assert (morphium.listDatabases().contains(morphium.getConfig().connectionSettings().getDatabase())); - assert (morphium.listCollections().contains(morphium.getMapper().getCollectionName(UncachedObject.class))); + TestUtils.waitForConditionToBecomeTrue(5000, "Collection not listed", + () -> morphium.listCollections().contains(morphium.getMapper().getCollectionName(UncachedObject.class))); + assertFalse(morphium.listDatabases().isEmpty()); + assertTrue(morphium.listDatabases().contains(morphium.getConfig().connectionSettings().getDatabase())); } @ParameterizedTest @@ -126,33 +130,33 @@ public void postUpdate(Morphium m, Class cls, Enum updateType) { UncachedObject uc = new UncachedObject("value", 12); morphium.store(uc); - Thread.sleep(500); - assert (preStore.get() == 1); - assert (postStore.get() == 1); + TestUtils.waitForConditionToBecomeTrue(5000, "Store listeners not called", + () -> postStore.get() == 1); + assertEquals(1, preStore.get()); morphium.createQueryFor(UncachedObject.class).f("_id").eq(uc.getMorphiumId()).get(); - assert (postLoad.get() == 1); + assertTrue((postLoad.get() == 1)); postLoad.set(0); Thread.sleep(500); morphium.createQueryFor(UncachedObject.class).f("_id").eq(uc.getMorphiumId()).asList(); - assert (postLoad.get() == 2); //one for each element, one for the whole list - two listeners! + assertTrue((postLoad.get() == 2)); //one for each element, one for the whole list - two listeners! morphium.createQueryFor(UncachedObject.class).f("_id").eq(uc.getMorphiumId()).delete(); - assert (preRemove.get() == 1); - assert (postRemove.get() == 1); + assertTrue((preRemove.get() == 1)); + assertTrue((postRemove.get() == 1)); morphium.dropCollection(UncachedObject.class); - assert (preDrop.get() == 1); - assert (postDrop.get() == 1); + assertTrue((preDrop.get() == 1)); + assertTrue((postDrop.get() == 1)); morphium.removeListener(lst); preStore.set(0); uc = new UncachedObject("value", 12); morphium.store(uc); Thread.sleep(50); - assert (preStore.get() == 0); + assertTrue((preStore.get() == 0)); } @@ -161,11 +165,13 @@ public void postUpdate(Morphium m, Class cls, Enum updateType) { public void testUnset(Morphium morphium) throws Exception { UncachedObject uc = new UncachedObject("val", 123); morphium.store(uc); - Thread.sleep(50); + TestUtils.waitForConditionToBecomeTrue(5000, "Object not stored", + () -> morphium.createQueryFor(UncachedObject.class).f("_id").eq(uc.getMorphiumId()).countAll() == 1); morphium.unsetInEntity(uc, UncachedObject.Fields.strValue); - Thread.sleep(500); - morphium.reread(uc); - assert (uc.getStrValue() == null); + TestUtils.waitForConditionToBecomeTrue(5000, "Unset not persisted", () -> { + morphium.reread(uc); + return uc.getStrValue() == null; + }); } @ParameterizedTest @@ -173,12 +179,14 @@ public void testUnset(Morphium morphium) throws Exception { public void testSet(Morphium morphium) throws Exception { UncachedObject uc = new UncachedObject("val", 123); morphium.store(uc); - Thread.sleep(50); + TestUtils.waitForConditionToBecomeTrue(5000, "Object not stored", + () -> morphium.createQueryFor(UncachedObject.class).f("_id").eq(uc.getMorphiumId()).countAll() == 1); morphium.setInEntity(uc, UncachedObject.Fields.strValue, "other"); - assert (uc.getStrValue().equals("other")); - Thread.sleep(500); - morphium.reread(uc); - assert (uc.getStrValue().equals("other")); + assertTrue((uc.getStrValue().equals("other"))); + TestUtils.waitForConditionToBecomeTrue(5000, "Set not persisted", () -> { + morphium.reread(uc); + return "other".equals(uc.getStrValue()); + }); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/NameProviderTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/NameProviderTest.java index fe1bacda3..6313f01d0 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/NameProviderTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/NameProviderTest.java @@ -15,6 +15,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -28,7 +29,7 @@ public class NameProviderTest extends MultiDriverTestBase { @MethodSource("getMorphiumInstancesNoSingle") public void testNameProvider(Morphium morphium) { String colName = morphium.getMapper().getCollectionName(LogObject.class); - assert (colName.endsWith("_Test")); + assertTrue((colName.endsWith("_Test"))); } @ParameterizedTest @@ -46,10 +47,10 @@ public void testStoreWithNameProvider(Morphium morphium) { waitForAsyncOperationsToStart(morphium, 1000); TestUtils.waitForWrites(morphium, log); String colName = morphium.getMapper().getCollectionName(LogObject.class); - assert (colName.endsWith("_Test")); + assertTrue((colName.endsWith("_Test"))); // DBCollection col = morphium.getDatabase().getCollection(colName); long count = morphium.createQueryFor(LogObject.class, colName).countAll(); - assert (count == 100) : "Error - did not store?? " + count; + assertTrue((count == 100), () -> String.valueOf("Error - did not store?? " + count)); } @@ -59,7 +60,7 @@ public void overrideNameProviderTest(Morphium morphium) { morphium.clearCollection(UncachedObject.class); morphium.getMapper().setNameProviderForClass(UncachedObject.class, new MyNp()); String col = morphium.getMapper().getCollectionName(UncachedObject.class); - assert (col.equals("UncachedObject_Test")) : "Error - name is wrong: " + col; + assertTrue((col.equals("UncachedObject_Test")), () -> String.valueOf("Error - name is wrong: " + col)); morphium.getMapper().setNameProviderForClass(UncachedObject.class, new DefaultNameProvider()); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/NetworkRetryTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/NetworkRetryTest.java index ed6fc4054..4261604be 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/NetworkRetryTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/NetworkRetryTest.java @@ -14,6 +14,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -62,7 +63,7 @@ public void networkRetryTestGet(Morphium morphium) throws Exception { for (int i = 1; i <= 1000; i++) { Query q = morphium.createQueryFor(UncachedObject.class); q = q.f("counter").eq(i); - assert (q.get().getCounter() == i); + assertTrue((q.get().getCounter() == i)); log.info("read " + i); Thread.sleep(500); } @@ -84,7 +85,7 @@ public void networkRetryTestComplexQuery(Morphium morphium) throws Exception { Map o = UtilsMap.of("counter", i + 1); List lst = q.rawQuery(o).asList(); log.info("read " + i); - assert (lst.get(0).getCounter() == i + 1); + assertTrue((lst.get(0).getCounter() == i + 1)); Thread.sleep(500); } } @@ -106,7 +107,7 @@ public void networkRetryTestIterator(Morphium morphium) throws Exception { Iterable it = q.asIterable(10); for (UncachedObject ob : it) { last++; - assert (ob.getCounter() == last); + assertTrue((ob.getCounter() == last)); Thread.sleep(500); } } @@ -199,7 +200,7 @@ public void pushTest(Morphium morphium) throws Exception { morphium.push(lc, "long_list", 12346L); morphium.push(lc, "long_list", 12347L); ListContainer cont = lc.get(); - assert (cont.getLongList().contains(12345L)) : "No push?"; + assertTrue((cont.getLongList().contains(12345L)), "No push?"); log.info("Pushed..."); Thread.sleep(1000); } @@ -232,7 +233,7 @@ public void pushAllTest(Morphium morphium) throws Exception { lst.add(12L); morphium.pushAll(lc, "long_list", lst, false, false); ListContainer cont = lc.get(); - assert (cont.getLongList().contains(12345L)) : "No push?"; + assertTrue((cont.getLongList().contains(12345L)), "No push?"); log.info("Pushed..."); Thread.sleep(1000); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/NonEntitySerialization.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/NonEntitySerialization.java index b909b22d2..53d852f72 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/NonEntitySerialization.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/NonEntitySerialization.java @@ -17,6 +17,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Created by stephan on 18.11.14. @@ -36,7 +37,7 @@ public void testNonEntity(Morphium morphium) throws Exception { log.debug(obj.toString()); NonEntity ne2 = morphium.getMapper().deserialize(NonEntity.class, obj); - assert (ne2.getInteger() == 42); + assertTrue((ne2.getInteger() == 42)); log.debug("Successful read:" + ne2); } @@ -60,7 +61,7 @@ public void testNonEntityList(Morphium morphium) throws Exception { assertNotNull(nc2.getList().get(0)); ; NonEntity ne2 = (NonEntity) nc2.getList().get(0); - assert (ne2.getInteger() == 42); + assertTrue((ne2.getInteger() == 42)); //now store to Mongo morphium.dropCollection(NonEntityContainer.class); @@ -72,8 +73,8 @@ public void testNonEntityList(Morphium morphium) throws Exception { assertNotNull(nc2.getList().get(0)); ; ne2 = (NonEntity) nc2.getList().get(0); - assert (ne2.getInteger() == 42); - assert (nc2.getList().get(1).equals("Some string")) : "Wrong Value: " + nc2.getList().get(1); + assertTrue((ne2.getInteger() == 42)); + assertTrue((nc2.getList().get(1).equals("Some string")), String.valueOf("Wrong Value: " + nc2.getList().get(1))); } @ParameterizedTest @@ -99,7 +100,7 @@ public void testNonEntityMap(Morphium morphium) throws Exception { assertNotNull(nc2.getMap().get("Serialized")); ; NonEntity ne2 = (NonEntity) nc2.getMap().get("Serialized"); - assert (ne2.getInteger() == 42); + assertTrue((ne2.getInteger() == 42)); //now store to Mongo morphium.dropCollection(NonEntityContainer.class); @@ -111,7 +112,7 @@ public void testNonEntityMap(Morphium morphium) throws Exception { assertNotNull(nc2.getMap().get("Serialized")); ; ne2 = (NonEntity) nc2.getMap().get("Serialized"); - assert (ne2.getInteger() == 42); + assertTrue((ne2.getInteger() == 42)); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/NonObjectIdTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/NonObjectIdTest.java index bb00b3c9c..d25f0e43e 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/NonObjectIdTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/NonObjectIdTest.java @@ -12,6 +12,7 @@ import java.util.Date; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -51,8 +52,8 @@ public void nonObjectIdTest(Morphium morphium) throws Exception { TestUtils.waitForWrites(morphium, log); Thread.sleep(1500); long cnt = morphium.createQueryFor(Person.class).countAll(); - assert(cnt == 3) : "Count wrong: " + cnt; - assert(morphium.findById(Person.class, "BBC123").getName().equals("CHANGED")); + assertTrue((cnt == 3), () -> String.valueOf("Count wrong: " + cnt)); + assertTrue((morphium.findById(Person.class, "BBC123").getName().equals("CHANGED"))); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ObjectMapperAnnotationHelperTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ObjectMapperAnnotationHelperTest.java index 9fe9e4270..814ae8d63 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ObjectMapperAnnotationHelperTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ObjectMapperAnnotationHelperTest.java @@ -28,7 +28,7 @@ public void testCreateCamelCase(Morphium morphium) { @MethodSource("getMorphiumInstancesNoSingle") public void testConvertCamelCase(Morphium morphium) { AnnotationAndReflectionHelper om = new AnnotationAndReflectionHelper(true); - assert (om.convertCamelCase("thisIsATest").equals("this_is_a_test")) : "Conversion failed!"; + assertTrue((om.convertCamelCase("thisIsATest").equals("this_is_a_test")), "Conversion failed!"); } @ParameterizedTest @@ -49,8 +49,8 @@ public void testDisableConvertCamelCase(Morphium morphium) { @MethodSource("getMorphiumInstancesNoSingle") public void testGetCollectionName(Morphium morphium) { MorphiumObjectMapper om = morphium.getMapper(); - assert (om.getCollectionName(CachedObject.class).equals("cached_object")) : "Cached object test failed"; - assert (om.getCollectionName(UncachedObject.class).equals("uncached_object")) : "Uncached object test failed"; + assertTrue((om.getCollectionName(CachedObject.class).equals("cached_object")), "Cached object test failed"); + assertTrue((om.getCollectionName(UncachedObject.class).equals("uncached_object")), "Uncached object test failed"); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ObjectMapperCollectionsMappingTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ObjectMapperCollectionsMappingTest.java index ec8d23f96..625e15866 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ObjectMapperCollectionsMappingTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ObjectMapperCollectionsMappingTest.java @@ -41,7 +41,7 @@ public void listValueTest(Morphium morphium) { MapListObject mo = om.deserialize(MapListObject.class, marshall); System.out.println("Mo: " + mo.getName()); System.out.println("lst: " + mo.getListValue()); - assert (mo.getName().equals(o.getName())) : "Names not equal?!?!?"; + assertTrue((mo.getName().equals(o.getName())), "Names not equal?!?!?"); for (int i = 0; i < lst.size(); i++) { Object listValueNew = mo.getListValue().get(i); Object listValueOrig = o.getListValue().get(i); @@ -238,7 +238,7 @@ public void objectMapperListOfMapOfListOfStringTest(Morphium morphium) { assertInstanceOf(String.class, ((List) ((Map) ((List) obj.get("list")).get(0)).get("tst1")).get(0)); ListOfMapOfListOfString lst6 = map.deserialize(ListOfMapOfListOfString.class, obj); - assert (lst6.list.size() == 2); + assertTrue((lst6.list.size() == 2)); assertNotNull(lst6.list.get(0)); ; assertNotNull(lst6.list.get(0).get("tst1")); diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ObjectMapperImplTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ObjectMapperImplTest.java index 28c7246f5..e86d05295 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ObjectMapperImplTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ObjectMapperImplTest.java @@ -27,6 +27,7 @@ import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; @SuppressWarnings({"unchecked", "rawtypes"}) @Tag("core") @@ -40,7 +41,7 @@ public void idTest() { UncachedObject o = new UncachedObject("test", 1234); o.setMorphiumId(new MorphiumId()); Map m = OM.serialize(o); - assert (m.get("_id") instanceof ObjectId); + assertTrue((m.get("_id") instanceof ObjectId)); UncachedObject uc = OM.deserialize(UncachedObject.class, m); assertNotNull(uc.getMorphiumId()); ; @@ -51,7 +52,7 @@ public void idTest() { public void simpleParseFromStringTest() throws Exception { String json = "{ \"value\":\"test\",\"counter\":123}"; UncachedObject uc = OM.deserialize(UncachedObject.class, json); - assert (uc.getCounter() == 123); + assertTrue((uc.getCounter() == 123)); } @Test @@ -62,8 +63,8 @@ public void objectToStringParseTest() { o.setCounter(1234); Map dbo = OM.serialize(o); UncachedObject uc = OM.deserialize(UncachedObject.class, dbo); - assert (uc.getCounter() == 1234); - assert (uc.getLongData()[0] == 1); + assertTrue((uc.getCounter() == 1234)); + assertTrue((uc.getLongData()[0] == 1)); } @@ -77,16 +78,16 @@ public void listContainerStringParseTest() { o.addString("string4"); Map dbo = OM.serialize(o); ListContainer uc = OM.deserialize(ListContainer.class, dbo); - assert (uc.getStringList().size() == 4); - assert (uc.getStringList().get(0).equals("string1")); - assert (uc.getLongList().size() == 1); + assertTrue((uc.getStringList().size() == 4)); + assertTrue((uc.getStringList().get(0).equals("string1"))); + assertTrue((uc.getLongList().size() == 1)); } @Test public void testCreateCamelCase() { AnnotationAndReflectionHelper om = new AnnotationAndReflectionHelper(true); - assert (om.createCamelCase("this_is_a_test", false).equals("thisIsATest")) : "Error camel case translation not working"; - assert (om.createCamelCase("a_test_this_is", true).equals("ATestThisIs")) : "Error - capitalized String wrong"; + assertTrue((om.createCamelCase("this_is_a_test", false).equals("thisIsATest")), "Error camel case translation not working"); + assertTrue((om.createCamelCase("a_test_this_is", true).equals("ATestThisIs")), "Error - capitalized String wrong"); } @@ -94,7 +95,7 @@ public void testCreateCamelCase() { @Test public void testConvertCamelCase() { AnnotationAndReflectionHelper om = new AnnotationAndReflectionHelper(true); - assert (om.convertCamelCase("thisIsATest").equals("this_is_a_test")) : "Conversion failed!"; + assertTrue((om.convertCamelCase("thisIsATest").equals("this_is_a_test")), "Conversion failed!"); } @Test @@ -102,18 +103,18 @@ public void testDisableConvertCamelCase() { AnnotationAndReflectionHelper om = new AnnotationAndReflectionHelper(false); String fn = om.getMongoFieldName(UncachedObject.class, "intData"); - assert (fn.equals("intData")) : "Conversion failed! " + fn; + assertTrue((fn.equals("intData")), String.valueOf("Conversion failed! " + fn)); om = new AnnotationAndReflectionHelper(true); fn = om.getMongoFieldName(UncachedObject.class, "intData"); - assert (fn.equals("int_data")) : "Conversion failed! " + fn; + assertTrue((fn.equals("int_data")), String.valueOf("Conversion failed! " + fn)); } @Test public void testGetCollectionName() { - assert (OM.getCollectionName(CachedObject.class).equals("cached_object")) : "Cached object test failed"; - assert (OM.getCollectionName(UncachedObject.class).equals("uncached_object")) : "Uncached object test failed"; + assertTrue((OM.getCollectionName(CachedObject.class).equals("cached_object")), "Cached object test failed"); + assertTrue((OM.getCollectionName(UncachedObject.class).equals("uncached_object")), "Uncached object test failed"); } @Test @@ -121,11 +122,11 @@ public void massiveParallelGetCollectionNameTest() { for (int i = 0; i < 2; i++) { new Thread(() -> { - assert (OM.getCollectionName(CachedObject.class).equals("cached_object")) : "Cached object test failed"; + assertTrue((OM.getCollectionName(CachedObject.class).equals("cached_object")), "Cached object test failed"); Thread.yield(); - assert (OM.getCollectionName(UncachedObject.class).equals("uncached_object")) : "Uncached object test failed"; + assertTrue((OM.getCollectionName(UncachedObject.class).equals("uncached_object")), "Uncached object test failed"); Thread.yield(); - assert (OM.getCollectionName(ComplexObject.class).equals("ComplexObject")) : "complex object test failed"; + assertTrue((OM.getCollectionName(ComplexObject.class).equals("ComplexObject")), "complex object test failed"); }).start(); } Thread.yield(); @@ -142,7 +143,7 @@ public void testMarshall() { String s = Utils.toJsonString(dbo); System.out.println("Marshalling was: " + s); // With new behavior, null values are serialized as explicit nulls (not omitted) - assert (MultiDriverTestBase.stringWordCompare(s, "{ \"float_data\" : null, \"dval\" : 0.0, \"double_data\" : null, \"str_value\" : \"This \" is $ test\", \"long_data\" : null, \"binary_data\" : null, \"counter\" : 12345, \"int_data\" : null } ")) : "String creation failed?" + s; + assertTrue((MultiDriverTestBase.stringWordCompare(s, "{ \"float_data\" : null, \"dval\" : 0.0, \"double_data\" : null, \"str_value\" : \"This \" is $ test\", \"long_data\" : null, \"binary_data\" : null, \"counter\" : 12345, \"int_data\" : null } ")), () -> String.valueOf("String creation failed?" + s)); o = OM.deserialize(UncachedObject.class, dbo); log.info("Text is: " + o.getStrValue()); } @@ -163,16 +164,16 @@ public void testGetId() { o.setStrValue("This \" is $ test"); o.setMorphiumId(new MorphiumId()); Object id = an.getId(o); - assert (id.equals(o.getMorphiumId())) : "IDs not equal!"; + assertTrue((id.equals(o.getMorphiumId())), "IDs not equal!"); } @Test public void testIsEntity() { AnnotationAndReflectionHelper om = new AnnotationAndReflectionHelper(true); - assert (om.isEntity(UncachedObject.class)) : "Uncached Object no Entity?=!?=!?"; - assert (om.isEntity(new UncachedObject())) : "Uncached Object no Entity?=!?=!?"; - assert (!om.isEntity("")) : "String is an Entity?"; + assertTrue((om.isEntity(UncachedObject.class)), "Uncached Object no Entity?=!?=!?"); + assertTrue((om.isEntity(new UncachedObject())), "Uncached Object no Entity?=!?=!?"); + assertTrue((!om.isEntity("")), "String is an Entity?"); } @Test @@ -181,7 +182,7 @@ public void testGetValue() { UncachedObject o = new UncachedObject(); o.setCounter(12345); o.setStrValue("This \" is $ test"); - assert (an.getValue(o, "counter").equals(12345)) : "Value not ok!"; + assertTrue((an.getValue(o, "counter").equals(12345)), "Value not ok!"); } @@ -191,7 +192,7 @@ public void testSetValue() { UncachedObject o = new UncachedObject(); o.setCounter(12345); om.setValue(o, "A test", "str_value"); - assert ("A test".equals(o.getStrValue())) : "Value not set"; + assertTrue(("A test".equals(o.getStrValue())), "Value not set"); } @@ -221,12 +222,12 @@ public void complexObjectTest() { // Unmarshalling stuff co = OM.deserialize(ComplexObject.class, marshall); - assert (co.getEntityEmbeded().getMorphiumId() == null) : "Embeded entity got a mongoID?!?!?!"; + assertTrue((co.getEntityEmbeded().getMorphiumId() == null), "Embeded entity got a mongoID?!?!?!"); co.getEntityEmbeded().setMorphiumId(embedId); // need to set ID // manually, as it won't // be stored! String st2 = Utils.toJsonString(co); - assert (MultiDriverTestBase.stringWordCompare(st, st2)) : "Strings not equal?\n" + st + "\n" + st2; + assertTrue((MultiDriverTestBase.stringWordCompare(st, st2)), () -> String.valueOf("Strings not equal?\n" + st + "\n" + st2)); assertNotNull(co.getEmbed(), "Embedded value not found!"); } @@ -237,7 +238,7 @@ public void idSerializeDeserializeTest() { Map tst = OM.serialize(uc); UncachedObject uc2 = OM.deserialize(UncachedObject.class, tst); - assert (uc2.getMorphiumId().equals(uc.getMorphiumId())); + assertTrue((uc2.getMorphiumId().equals(uc.getMorphiumId()))); } @Test @@ -251,7 +252,7 @@ public void nullValueTests() { } o.setEinText("Ein Text"); obj = OM.serialize(o); - assert (!obj.containsKey("trans")) : "Transient field used?!?!?"; + assertTrue((!obj.containsKey("trans")), "Transient field used?!?!?"); } @Test @@ -272,17 +273,17 @@ public void listValueTest() { // class_name=de.caluga.test.mongo.suite.data.UncachedObject}], // name=Simple List}")) : "Marshall not ok: " + m; // With new behavior, null values are serialized as explicit nulls (not omitted) - assert (MultiDriverTestBase.stringWordCompare(m, "{list_value=[A Value, 27.0, {float_data=null, dval=0.0, double_data=null, str_value=null, long_data=null, binary_data=null, counter=0, class_name=uc, int_data=null}], map_value=null, name=Simple List, map_list_value=null}")); + assertTrue((MultiDriverTestBase.stringWordCompare(m, "{list_value=[A Value, 27.0, {float_data=null, dval=0.0, double_data=null, str_value=null, long_data=null, binary_data=null, counter=0, class_name=uc, int_data=null}], map_value=null, name=Simple List, map_list_value=null}"))); MapListObject mo = OM.deserialize(MapListObject.class, marshall); System.out.println("Mo: " + mo.getName()); System.out.println("lst: " + mo.getListValue()); - assert (mo.getName().equals(o.getName())) : "Names not equal?!?!?"; + assertTrue((mo.getName().equals(o.getName())), "Names not equal?!?!?"); for (int i = 0; i < lst.size(); i++) { Object listValueNew = mo.getListValue().get(i); Object listValueOrig = o.getListValue().get(i); - assert (listValueNew.getClass().equals(listValueOrig.getClass())) : "Classes differ: " + listValueNew.getClass() + " - " + listValueOrig.getClass(); - assert (listValueNew.equals(listValueOrig)) : "Value not equals in list: " + listValueNew + " vs. " + listValueOrig; + assertTrue((listValueNew.getClass().equals(listValueOrig.getClass())), () -> String.valueOf("Classes differ: " + listValueNew.getClass() + " - " + listValueOrig.getClass())); + assertTrue((listValueNew.equals(listValueOrig)), () -> String.valueOf("Value not equals in list: " + listValueNew + " vs. " + listValueOrig)); } System.out.println("test Passed!"); @@ -310,18 +311,18 @@ public void mapValueTest() { // \"This is a string\" } , \"name\" : \"A map-value\" } ")) : "Value // not marshalled corectly"; // With new behavior, null values are serialized as explicit nulls (not omitted) - assert (MultiDriverTestBase.stringWordCompare(m, "{ \"list_value\" : null, \"map_value\" : { \"Entity\" : { \"float_data\" : null, \"dval\" : 0.0, \"double_data\" : null, \"str_value\" : null, \"long_data\" : null, \"binary_data\" : null, \"counter\" : 0, \"class_name\" : \"uc\", \"int_data\" : null } , \"a primitive value\" : 42, \"null\" : null, \"double\" : 42.0, \"a_string\" : \"This is a string\" } , \"name\" : \"A map-value\", \"map_list_value\" : null }")) : "Value not marshalled corectly"; + assertTrue((MultiDriverTestBase.stringWordCompare(m, "{ \"list_value\" : null, \"map_value\" : { \"Entity\" : { \"float_data\" : null, \"dval\" : 0.0, \"double_data\" : null, \"str_value\" : null, \"long_data\" : null, \"binary_data\" : null, \"counter\" : 0, \"class_name\" : \"uc\", \"int_data\" : null } , \"a primitive value\" : 42, \"null\" : null, \"double\" : 42.0, \"a_string\" : \"This is a string\" } , \"name\" : \"A map-value\", \"map_list_value\" : null }")), "Value not marshalled corectly"); MapListObject mo = OM.deserialize(MapListObject.class, marshall); - assert (mo.getName().equals("A map-value")) : "Name error"; + assertTrue((mo.getName().equals("A map-value")), "Name error"); assertNotNull(mo.getMapValue(), "map value is null????"); for (String k : mo.getMapValue().keySet()) { Object v = mo.getMapValue().get(k); if (v == null) { - assert (o.getMapValue().get(k) == null) : "v==null but original not?"; + assertTrue((o.getMapValue().get(k) == null), "v==null but original not?"); } else { - assert (o.getMapValue().get(k).getClass().equals(v.getClass())) : "Classes differ: " + o.getMapValue().get(k).getClass().getName() + " != " + v.getClass().getName(); - assert (o.getMapValue().get(k).equals(v)) : "Value not equal, key: " + k; + assertTrue((o.getMapValue().get(k).getClass().equals(v.getClass())), () -> String.valueOf("Classes differ: " + o.getMapValue().get(k).getClass().getName() + " != " + v.getClass().getName())); + assertTrue((o.getMapValue().get(k).equals(v)), () -> String.valueOf("Value not equal, key: " + k)); } } @@ -346,14 +347,14 @@ public void objectMapperSpeedTest() { long dur = System.currentTimeMillis() - start; log.info("Mapping of UncachedObject 25000 times took " + dur + "ms"); - assert (dur < 5000); + assertTrue((dur < 5000)); start = System.currentTimeMillis(); for (int i = 0; i < 25000; i++) { UncachedObject uc = OM.deserialize(UncachedObject.class, marshall); } dur = System.currentTimeMillis() - start; log.info("De-Marshalling of UncachedObject 25000 times took " + dur + "ms"); - assert (dur < 5000); + assertTrue((dur < 5000)); } @Test @@ -374,14 +375,14 @@ public void objectMapperSpeedTest2() { long dur = System.currentTimeMillis() - start; log.info("Mapping of UncachedObject 25000 times took " + dur + "ms"); - assert (dur < 5000); + assertTrue((dur < 5000)); start = System.currentTimeMillis(); for (int i = 0; i < 25000; i++) { UncachedObject uc = OM.deserialize(UncachedObject.class, marshall); } dur = System.currentTimeMillis() - start; log.info("De-Marshalling of UncachedObject 25000 times took " + dur + "ms"); - assert (dur < 5000); + assertTrue((dur < 5000)); } @Test @@ -390,7 +391,7 @@ public void rsStatusTest() throws Exception { ReplicaSetConf c = OM.deserialize(ReplicaSetConf.class, json); assertNotNull(c); ; - assert (c.getMembers().size() == 3); + assertTrue((c.getMembers().size() == 3)); } @Test @@ -402,9 +403,9 @@ public void embeddedListTest() { Map obj = OM.serialize(co); assertNotNull(obj.get("embeddedObjectList")); ; - assert (((List) obj.get("embeddedObjectList")).size() == 2); + assertTrue((((List) obj.get("embeddedObjectList")).size() == 2)); ComplexObject co2 = OM.deserialize(ComplexObject.class, obj); - assert (co2.getEmbeddedObjectList().size() == 2); + assertTrue((co2.getEmbeddedObjectList().size() == 2)); assertNotNull(co2.getEmbeddedObjectList().get(0).getName()); ; @@ -418,8 +419,8 @@ public void binaryDataTest() { Map obj = OM.serialize(o); assertNotNull(obj.get("binary_data")); ; - assert (obj.get("binary_data").getClass().isArray()); - assert (obj.get("binary_data").getClass().getComponentType().equals(byte.class)); + assertTrue((obj.get("binary_data").getClass().isArray())); + assertTrue((obj.get("binary_data").getClass().getComponentType().equals(byte.class))); } @Test @@ -431,8 +432,8 @@ public void noDefaultConstructorTest() throws Exception { o = OM.deserialize(NoDefaultConstructorUncachedObject.class, serialized); assertNotNull(o); ; - assert (o.getCounter() == 15); - assert (o.getStrValue().equals("test")); + assertTrue((o.getCounter() == 15)); + assertTrue((o.getStrValue().equals("test"))); } @Test @@ -458,9 +459,9 @@ public void objectMapperNGTest() { assertNotNull(obj.get("str_value")); ; - assert (obj.get("str_value") instanceof String); - assert (obj.get("counter") instanceof Integer); - assert (obj.get("long_data") instanceof ArrayList); + assertTrue((obj.get("str_value") instanceof String)); + assertTrue((obj.get("counter") instanceof Integer)); + assertTrue((obj.get("long_data") instanceof ArrayList)); MappedObject mo = new MappedObject(); mo.id = "test"; @@ -471,7 +472,7 @@ public void objectMapperNGTest() { obj = OM.serialize(mo); assertNotNull(obj.get("uc")); ; - assert (((Map) obj.get("uc")).get("_id") == null); + assertTrue((((Map) obj.get("uc")).get("_id") == null)); BIObject bo = new BIObject(); bo.id = new MorphiumId(); @@ -479,8 +480,8 @@ public void objectMapperNGTest() { bo.biValue = new BigInteger("123afd33", 16); obj = OM.serialize(bo); - assert (obj.get("_id") instanceof ObjectId || obj.get("_id") instanceof String || obj.get("_id") instanceof MorphiumId); - assert (obj.get("bi_value") instanceof Map); + assertTrue((obj.get("_id") instanceof ObjectId || obj.get("_id") instanceof String || obj.get("_id") instanceof MorphiumId)); + assertTrue((obj.get("bi_value") instanceof Map)); } @@ -513,9 +514,9 @@ public void setTest() { Map m = OM.serialize(so); assertNotNull(m.get("set_of_strings")); ; - assert (m.get("set_of_strings") instanceof List); - assert (((List) m.get("set_of_strings")).size() == 3); - assert (((List) m.get("set_of_u_c")).size() == 1); + assertTrue((m.get("set_of_strings") instanceof List)); + assertTrue((((List) m.get("set_of_strings")).size() == 3)); + assertTrue((((List) m.get("set_of_u_c")).size() == 1)); SetObject setObject = OM.deserialize(SetObject.class, m); assertNotNull(setObject); @@ -523,24 +524,24 @@ public void setTest() { setObject.setOfStrings.contains("test"); setObject.setOfStrings.contains("test2"); setObject.setOfStrings.contains("test3"); - assert (setObject.setOfUC.iterator().next() instanceof UncachedObject); + assertTrue((setObject.setOfUC.iterator().next() instanceof UncachedObject)); - assert (setObject.listOfSetOfStrings.size() == 2); + assertTrue((setObject.listOfSetOfStrings.size() == 2)); Set firstSetOfStrings = setObject.listOfSetOfStrings.get(0); - assert (firstSetOfStrings.size() == 2); - assert (firstSetOfStrings.contains("Test1")); - assert (firstSetOfStrings.contains("Test2")); + assertTrue((firstSetOfStrings.size() == 2)); + assertTrue((firstSetOfStrings.contains("Test1"))); + assertTrue((firstSetOfStrings.contains("Test2"))); Set secondSetOfStrings = setObject.listOfSetOfStrings.get(1); - assert (secondSetOfStrings.size() == 2); - assert (secondSetOfStrings.contains("Test3")); - assert (secondSetOfStrings.contains("Test4")); + assertTrue((secondSetOfStrings.size() == 2)); + assertTrue((secondSetOfStrings.contains("Test3"))); + assertTrue((secondSetOfStrings.contains("Test4"))); Set t1 = setObject.mapOfSetOfStrings.get("t1"); - assert (t1.contains("test1")); - assert (t1.contains("test11")); + assertTrue((t1.contains("test1"))); + assertTrue((t1.contains("test11"))); Set t2 = setObject.mapOfSetOfStrings.get("t2"); - assert (t2.contains("test2")); - assert (t2.contains("test21")); + assertTrue((t2.contains("test2"))); + assertTrue((t2.contains("test21"))); } @Test @@ -579,24 +580,24 @@ public void setTestDeserializeLegacy() { setObject.setOfStrings.contains("test"); setObject.setOfStrings.contains("test2"); setObject.setOfStrings.contains("test3"); - assert (setObject.setOfUC.iterator().next() instanceof UncachedObject); + assertTrue((setObject.setOfUC.iterator().next() instanceof UncachedObject)); - assert (setObject.listOfSetOfStrings.size() == 2); + assertTrue((setObject.listOfSetOfStrings.size() == 2)); Set firstSetOfStrings = setObject.listOfSetOfStrings.get(0); - assert (firstSetOfStrings.size() == 2); - assert (firstSetOfStrings.contains("Test1")); - assert (firstSetOfStrings.contains("Test2")); + assertTrue((firstSetOfStrings.size() == 2)); + assertTrue((firstSetOfStrings.contains("Test1"))); + assertTrue((firstSetOfStrings.contains("Test2"))); Set secondSetOfStrings = setObject.listOfSetOfStrings.get(1); - assert (secondSetOfStrings.size() == 2); - assert (secondSetOfStrings.contains("Test3")); - assert (secondSetOfStrings.contains("Test4")); + assertTrue((secondSetOfStrings.size() == 2)); + assertTrue((secondSetOfStrings.contains("Test3"))); + assertTrue((secondSetOfStrings.contains("Test4"))); Set t1 = setObject.mapOfSetOfStrings.get("t1"); - assert (t1.contains("test1")); - assert (t1.contains("test11")); + assertTrue((t1.contains("test1"))); + assertTrue((t1.contains("test11"))); Set t2 = setObject.mapOfSetOfStrings.get("t2"); - assert (t2.contains("test2")); - assert (t2.contains("test21")); + assertTrue((t2.contains("test2"))); + assertTrue((t2.contains("test21"))); } @Test @@ -611,19 +612,19 @@ public void testListOfEmbedded() { Map obj = OM.serialize(lst); assertNotNull(obj.get("list")); ; - assert (obj.get("list") instanceof List); - assert (((List) obj.get("list")).get(0) instanceof Map); + assertTrue((obj.get("list") instanceof List)); + assertTrue((((List) obj.get("list")).get(0) instanceof Map)); ListOfEmbedded lst2 = OM.deserialize(ListOfEmbedded.class, obj); assertNotNull(lst2.list); ; - assert (lst2.list.size() == 4); - assert (lst2.list.get(0).getName().equals("nam")); + assertTrue((lst2.list.size() == 4)); + assertTrue((lst2.list.get(0).getName().equals("nam"))); ((Map) ((List) obj.get("list")).get(0)).remove("class_name"); lst2 = OM.deserialize(ListOfEmbedded.class, obj); - assert (lst2.list.get(0) instanceof EmbeddedObject); + assertTrue((lst2.list.get(0) instanceof EmbeddedObject)); } @@ -637,16 +638,16 @@ public void objectMapperListOfListOfUncachedTest() { lst3.list.get(0).get(0).add(new UncachedObject("test", 123)); Map obj = OM.serialize(lst3); - assert (obj.get("list") instanceof List); - assert (((List) obj.get("list")).get(0) instanceof List); - assert (((List) ((List) obj.get("list")).get(0)).get(0) instanceof List); - assert (((List) ((List) ((List) obj.get("list")).get(0)).get(0)).get(0) instanceof Map); + assertTrue((obj.get("list") instanceof List)); + assertTrue((((List) obj.get("list")).get(0) instanceof List)); + assertTrue((((List) ((List) obj.get("list")).get(0)).get(0) instanceof List)); + assertTrue((((List) ((List) ((List) obj.get("list")).get(0)).get(0)).get(0) instanceof Map)); ListOfListOfListOfUncached lst4 = OM.deserialize(ListOfListOfListOfUncached.class, obj); - assert (lst4.list.size() == 2); - assert (lst4.list.get(0).size() == 1); - assert (lst4.list.get(0).get(0).size() == 1); - assert (lst4.list.get(0).get(0).get(0).getStrValue().equals("test")); + assertTrue((lst4.list.size() == 2)); + assertTrue((lst4.list.get(0).size() == 1)); + assertTrue((lst4.list.get(0).get(0).size() == 1)); + assertTrue((lst4.list.get(0).get(0).get(0).getStrValue().equals("test"))); } public static class NoDefaultConstructorUncachedObject extends UncachedObject { @@ -665,13 +666,13 @@ public void objectMapperListOfMapOfListOfStringTest() { lst5.list.get(0).put("tst1", new ArrayList<>()); lst5.list.get(0).get("tst1").add("test"); Map obj = OM.serialize(lst5); - assert (obj.get("list") instanceof List); - assert (((List) obj.get("list")).get(0) instanceof Map); - assert (((Map) ((List) obj.get("list")).get(0)).get("tst1") instanceof List); - assert (((List) ((Map) ((List) obj.get("list")).get(0)).get("tst1")).get(0) instanceof String); + assertTrue((obj.get("list") instanceof List)); + assertTrue((((List) obj.get("list")).get(0) instanceof Map)); + assertTrue((((Map) ((List) obj.get("list")).get(0)).get("tst1") instanceof List)); + assertTrue((((List) ((Map) ((List) obj.get("list")).get(0)).get("tst1")).get(0) instanceof String)); ListOfMapOfListOfString lst6 = OM.deserialize(ListOfMapOfListOfString.class, obj); - assert (lst6.list.size() == 2); + assertTrue((lst6.list.size() == 2)); assertNotNull(lst6.list.get(0)); ; assertNotNull(lst6.list.get(0).get("tst1")); @@ -688,16 +689,16 @@ public void objectMapperListOfListOfStringTest() { lst.list.get(0).get(0).add("TEst1"); Map obj = OM.serialize(lst); - assert (obj.get("list") instanceof List); - assert (((List) obj.get("list")).get(0) instanceof List); - assert (((List) ((List) obj.get("list")).get(0)).get(0) instanceof List); - assert (((List) ((List) ((List) obj.get("list")).get(0)).get(0)).get(0) instanceof String); + assertTrue((obj.get("list") instanceof List)); + assertTrue((((List) obj.get("list")).get(0) instanceof List)); + assertTrue((((List) ((List) obj.get("list")).get(0)).get(0) instanceof List)); + assertTrue((((List) ((List) ((List) obj.get("list")).get(0)).get(0)).get(0) instanceof String)); ListOfListOfListOfString lst2 = OM.deserialize(ListOfListOfListOfString.class, obj); - assert (lst2.list.size() == 2); - assert (lst2.list.get(0).size() == 1); - assert (lst2.list.get(0).get(0).size() == 1); - assert (lst2.list.get(0).get(0).get(0).equals("TEst1")); + assertTrue((lst2.list.size() == 2)); + assertTrue((lst2.list.get(0).size() == 1)); + assertTrue((lst2.list.get(0).get(0).size() == 1)); + assertTrue((lst2.list.get(0).get(0).get(0).equals("TEst1"))); } @@ -740,7 +741,7 @@ public void enumTest() { assertNotNull(e2); ; - assert (e2.equals(e)); + assertTrue((e2.equals(e))); } @Test @@ -766,7 +767,7 @@ public void enumWithClassBodyTest() { assertNotNull(e2); ; - assert (e2.equals(e)); + assertTrue((e2.equals(e))); } @Test @@ -792,7 +793,7 @@ public void enumWithCustomToStringTest() { assertNotNull(e2); ; - assert (e2.equals(e)); + assertTrue((e2.equals(e))); } @Test @@ -808,7 +809,7 @@ public void enumInRawTest() { assertNotNull(e2); ; - assert (e2.equals(e)); + assertTrue((e2.equals(e))); } @Test @@ -848,11 +849,11 @@ public MyClass unmarshall(Object d) { MyClass mc = new MyClass(); mc.theValue = "a little Test"; Map map = OM.serialize(mc); - assert (map.get("class").equals(mc.getClass().getName())); - assert (map.get("value").equals("AMMENDED+" + mc.theValue)); + assertTrue((map.get("class").equals(mc.getClass().getName()))); + assertTrue((map.get("value").equals("AMMENDED+" + mc.theValue))); MyClass mc2 = OM.deserialize(MyClass.class, map); - assert (mc2.theValue.equals(mc.theValue)); + assertTrue((mc2.theValue.equals(mc.theValue))); } @Test @@ -881,18 +882,18 @@ public void testStructure() throws Exception { log.info("Deserialized!"); assertNotNull(c2); ; - assert (c2.id.equals(c.id)); - assert (c2.structureK.size() == c.structureK.size()); - assert (c2.structureK.get(0).get("String") instanceof String); - assert (c2.structureK.get(0).get("Integer") instanceof Integer); - assert (c2.structureK.get(0).get("List") instanceof List); - assert (c2.structureK.get(0).get("Map") == null); - assert (c2.structureK.get(1).get("String") instanceof String); - assert (c2.structureK.get(1).get("Integer") instanceof Integer); - assert (c2.structureK.get(1).get("List") instanceof List); + assertTrue((c2.id.equals(c.id))); + assertTrue((c2.structureK.size() == c.structureK.size())); + assertTrue((c2.structureK.get(0).get("String") instanceof String)); + assertTrue((c2.structureK.get(0).get("Integer") instanceof Integer)); + assertTrue((c2.structureK.get(0).get("List") instanceof List)); + assertTrue((c2.structureK.get(0).get("Map") == null)); + assertTrue((c2.structureK.get(1).get("String") instanceof String)); + assertTrue((c2.structureK.get(1).get("Integer") instanceof Integer)); + assertTrue((c2.structureK.get(1).get("List") instanceof List)); assertNotNull(c2.structureK.get(1).get("Map")); ; - assert (((Map) c2.structureK.get(1).get("Map")).get("key").equals(123)); + assertTrue((((Map) c2.structureK.get(1).get("Map")).get("key").equals(123))); log.info("All fine!"); } @@ -918,10 +919,10 @@ public void testArray() { Map obj = OM.serialize(a); ArrayTestObj a2 = OM.deserialize(ArrayTestObj.class, obj); - assert (Arrays.equals((byte[]) obj.get("byte_arr"), a.byteArr)) : "Byte array should be sento to mongo as is: " + obj.get("byteArr"); + assertTrue((Arrays.equals((byte[]) obj.get("byte_arr"), a.byteArr)), () -> String.valueOf("Byte array should be sento to mongo as is: " + obj.get("byteArr"))); assertNotNull(a2); ; - assert (a2.equals(a)); + assertTrue((a2.equals(a))); } @Embedded diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ObjectMapperSerializationTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ObjectMapperSerializationTest.java index 27041a96e..71ba7d6f9 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ObjectMapperSerializationTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ObjectMapperSerializationTest.java @@ -28,7 +28,7 @@ public void mapSerializationTest(Morphium morphium) { om.getMorphium().getConfig().objectMappingSettings().setWarnOnNoEntitySerialization(true); Map map = om.serialize(new Simple()); log.info("Got map"); - assert (map.get("test").toString().startsWith("test")); + assertTrue((map.get("test").toString().startsWith("test"))); Simple s = om.deserialize(Simple.class, map); log.info("Got simple"); @@ -38,7 +38,7 @@ public void mapSerializationTest(Morphium morphium) { m.put("simple", s); map = om.serializeMap(m, null); - assert (map.get("test").equals("testvalue")); + assertTrue((map.get("test").equals("testvalue"))); java.util.List lst = new java.util.ArrayList<>(); lst.add(new Simple()); @@ -47,7 +47,7 @@ public void mapSerializationTest(Morphium morphium) { @SuppressWarnings("unchecked") List serializedList = (List) (List) om.serializeIterable(lst, null, null); - assert (serializedList.size() == 3); + assertTrue((serializedList.size() == 3)); java.util.List deserializedList = om.deserializeList(serializedList); log.info("Deserialized"); @@ -142,7 +142,7 @@ public void idSerializeDeserializeTest(Morphium morphium) { Map tst = morphium.getMapper().serialize(uc); UncachedObject uc2 = morphium.getMapper().deserialize(UncachedObject.class, tst); - assert (uc2.getMorphiumId().equals(uc.getMorphiumId())); + assertTrue((uc2.getMorphiumId().equals(uc.getMorphiumId()))); } @ParameterizedTest diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/PolymorphismTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/PolymorphismTest.java index 4914289d9..b543c66d5 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/PolymorphismTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/PolymorphismTest.java @@ -19,6 +19,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Created with IntelliJ IDEA. @@ -72,7 +73,7 @@ public void subClasstest(Morphium morphium) throws Exception { ; pc = morphium.getMapper().deserialize(PolyContainer.class, obj); - assert (pc.aSubClass instanceof SubClass); + assertTrue((pc.aSubClass instanceof SubClass)); pc = new PolyContainer(); pc.aLotOfSubClasses = new ArrayList<>(); @@ -83,12 +84,12 @@ public void subClasstest(Morphium morphium) throws Exception { obj = morphium.getMapper().serialize(pc); assertNotNull(obj); ; - assert (((List) obj.get("a_lot_of_sub_classes")).size() == 3); + assertTrue((((List) obj.get("a_lot_of_sub_classes")).size() == 3)); pc = morphium.getMapper().deserialize(PolyContainer.class, obj); assertNotNull(pc); ; - assert (pc.aLotOfSubClasses.size() == 3); + assertTrue((pc.aLotOfSubClasses.size() == 3)); pc = new PolyContainer(); pc.aMapOfSubClasses = new HashMap<>(); diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QueryBuilderTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QueryBuilderTest.java index 2c0731a2a..30ef08c5b 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QueryBuilderTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QueryBuilderTest.java @@ -15,6 +15,7 @@ import java.util.Map; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; @SuppressWarnings("unchecked") @Tag("core") @@ -33,8 +34,7 @@ public void testQuery(Morphium morphium) { String str = Utils.toJsonString(dbObject); assertNotNull(str, "ToString is NULL?!?!?"); System.out.println("Query: " + str); - assert (str.trim().equals("{ \"$or\" : [ { \"counter\" : { \"$lte\" : 15 } } , { \"counter\" : { \"$gte\" : 10 } } , { \"$and\" : [ { \"counter\" : { \"$lt\" : 15 } } , { \"counter\" : { \"$gt\" : 10 } } , { \"str_value\" : \"hallo\" } , { \"str_value\" : { \"$ne\" : \"test\" } } ] } ] }")) - : "Query-Object wrong"; + assertTrue((str.trim().equals("{ \"$or\" : [ { \"counter\" : { \"$lte\" : 15 } } , { \"counter\" : { \"$gte\" : 10 } } , { \"$and\" : [ { \"counter\" : { \"$lt\" : 15 } } , { \"counter\" : { \"$gt\" : 10 } } , { \"str_value\" : \"hallo\" } , { \"str_value\" : { \"$ne\" : \"test\" } } ] } ] }")), "Query-Object wrong"); q = q.q(); q.f("counter").gt(0).f("counter").lt(10); dbObject = q.toQueryObject(); @@ -46,7 +46,7 @@ public void testQuery(Morphium morphium) { str = Utils.toJsonString(dbObject); assertNotNull(str, "ToString is NULL?!?!?"); System.out.println("Query: " + str); - assert (str.trim().equals("{ \"counter\" : { \"$mod\" : [ 10, 5] } }")) : "Query wrong"; + assertTrue((str.trim().equals("{ \"counter\" : { \"$mod\" : [ 10, 5] } }")), "Query wrong"); q = q.q(); //new query q = q.f("counter").gte(5).f("counter").lte(10); q.or(q.q().f("counter").eq(15), q.q().f(UncachedObject.Fields.counter).eq(22)); @@ -63,7 +63,7 @@ public void testComplexAndOr(Morphium morphium) { q = q.f("counter").lt(100).or(q.q().f("counter").eq(50), q.q().f(UncachedObject.Fields.counter).eq(101)); String s = Utils.toJsonString(q.toQueryObject()); log.info("Query: " + s); - assert (s.trim().equals("{ \"$and\" : [ { \"counter\" : { \"$lt\" : 100 } } , { \"$or\" : [ { \"counter\" : 50 } , { \"counter\" : 101 } ] } ] }")); + assertTrue((s.trim().equals("{ \"$and\" : [ { \"counter\" : { \"$lt\" : 100 } } , { \"$or\" : [ { \"counter\" : 50 } , { \"counter\" : 101 } ] } ] }"))); } @ParameterizedTest @@ -77,7 +77,7 @@ public void testOrder(Morphium morphium) { q = q.f("strValue").eq("test").f("counter").lt(1000); String str2 = Utils.toJsonString(q.toQueryObject()); log.info("Query2: " + str2); - assert (!str.equals(str2)); + assertTrue((!str.equals(str2))); q = q.q(); q = q.f("str_value").eq("test").f("counter").lt(1000).f("counter").gt(10); str = Utils.toJsonString(q.toQueryObject()); @@ -86,7 +86,7 @@ public void testOrder(Morphium morphium) { q = q.f("counter").gt(10).f("strValue").eq("test").f("counter").lt(1000); str = Utils.toJsonString(q.toQueryObject()); log.info("2nd Query2: " + str); - assert (!str.equals(str2)); + assertTrue((!str.equals(str2))); } @ParameterizedTest @@ -97,7 +97,7 @@ public void testToString(Morphium morphium) { String qStr = q.toString(); log.info("ToString: " + qStr); log.info("query: " + Utils.toJsonString(q.toQueryObject())); - assert (Utils.toJsonString(q.toQueryObject()).trim().equals("{ \"long_list\" : { \"$size\" : 10 } }")); + assertTrue((Utils.toJsonString(q.toQueryObject()).trim().equals("{ \"long_list\" : { \"$size\" : 10 } }"))); } @ParameterizedTest @@ -105,7 +105,7 @@ public void testToString(Morphium morphium) { public void testWhere(Morphium morphium) { Query q = morphium.createQueryFor(UncachedObject.class); q.where("this.value=5"); - assert (q.toQueryObject().get("$where").equals("this.value=5")); + assertTrue((q.toQueryObject().get("$where").equals("this.value=5"))); } @ParameterizedTest @@ -113,24 +113,24 @@ public void testWhere(Morphium morphium) { public void testF(Morphium morphium) { Query q = morphium.createQueryFor(UncachedObject.class); MongoField f = q.f("_id"); - assert (f.getFieldString().equals("_id")); + assertTrue((f.getFieldString().equals("_id"))); f = q.q().f(UncachedObject.Fields.morphiumId); - assert (f.getFieldString().equals("_id")); + assertTrue((f.getFieldString().equals("_id"))); MongoField f2 = morphium.createQueryFor(ComplexObject.class).f(ComplexObject.Fields.entityEmbeded, UncachedObject.Fields.counter); - assert (f2.getFieldString().equals("entityEmbeded.counter")); + assertTrue((f2.getFieldString().equals("entityEmbeded.counter"))); f2 = morphium.createQueryFor(ComplexObject.class).f(ComplexObject.Fields.entityEmbeded, UncachedObject.Fields.morphiumId); - assert (f2.getFieldString().equals("entityEmbeded._id")); + assertTrue((f2.getFieldString().equals("entityEmbeded._id"))); f2 = morphium.createQueryFor(ComplexObject.class).f("entity_embeded", "counter"); - assert (f2.getFieldString().equals("entityEmbeded.counter")); + assertTrue((f2.getFieldString().equals("entityEmbeded.counter"))); } @ParameterizedTest @MethodSource("getMorphiumInstancesNoSingle") public void testOverrideDB(Morphium morphium) { Query q = morphium.createQueryFor(UncachedObject.class); - assert (q.getDB().equals(morphium.getConfig().connectionSettings().getDatabase())); + assertTrue((q.getDB().equals(morphium.getConfig().connectionSettings().getDatabase()))); q.overrideDB("testDB"); - assert (q.getDB().equals("testDB")); + assertTrue((q.getDB().equals("testDB"))); } @ParameterizedTest @@ -152,7 +152,7 @@ public void testOr(Morphium morphium) { Map qo = q.toQueryObject(); assertNotNull(qo.get("$or")); ; - assert (((java.util.List) qo.get("$or")).size() == 2); + assertTrue((((java.util.List) qo.get("$or")).size() == 2)); assertNotNull(((java.util.List>) qo.get("$or")).get(0).get("counter")); ; assertNotNull(((java.util.List>) qo.get("$or")).get(1).get("str_value")); @@ -169,7 +169,7 @@ public void testNor(Morphium morphium) { Map qo = q.toQueryObject(); assertNotNull(qo.get("$nor")); ; - assert (((java.util.List) qo.get("$nor")).size() == 2); + assertTrue((((java.util.List) qo.get("$nor")).size() == 2)); assertNotNull(((java.util.List>) qo.get("$nor")).get(0).get("counter")); ; assertNotNull(((java.util.List>) qo.get("$nor")).get(1).get("str_value")); @@ -184,9 +184,9 @@ public void testQ(Morphium morphium) { q.where("this.test=5"); q.limit(12); q.sort("strValue"); - assert (q.q().getSort() == null || q.q().getSort().isEmpty()); - assert (q.q().getWhere() == null); - assert (q.q().toQueryObject().size() == 0); - assert (q.q().getLimit() == 0); + assertTrue((q.q().getSort() == null || q.q().getSort().isEmpty())); + assertTrue((q.q().getWhere() == null)); + assertTrue((q.q().toQueryObject().size() == 0)); + assertTrue((q.q().getLimit() == 0)); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QueryCountDistinctTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QueryCountDistinctTest.java index 6c791ad2b..892c12563 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QueryCountDistinctTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QueryCountDistinctTest.java @@ -13,6 +13,7 @@ import java.util.concurrent.atomic.AtomicLong; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("core") public class QueryCountDistinctTest extends MultiDriverTestBase { @@ -26,7 +27,7 @@ public void distinctTest(Morphium morphium) throws InterruptedException { Thread.sleep(100); List lt = morphium.createQueryFor(UncachedObject.class).distinct("counter"); - assert (lt.size() == 3); + assertTrue((lt.size() == 3)); } @ParameterizedTest @@ -52,8 +53,8 @@ public void testSize(Morphium morphium) throws InterruptedException { Query q = morphium.createQueryFor(ListContainer.class); q = q.f(ListContainer.Fields.longList).size(10); lc = q.get(); - assert (lc.getLongList().size() == 10); - assert (lc.getName().equals("A test")); + assertTrue((lc.getLongList().size() == 10)); + assertTrue((lc.getName().equals("A test"))); } @ParameterizedTest @@ -64,7 +65,7 @@ public void testCountAll(Morphium morphium) throws Exception { Query q = morphium.createQueryFor(UncachedObject.class); q.f(UncachedObject.Fields.counter).lt(100); q.limit(1); - assert (q.countAll() == 10) : "Wrong amount: " + q.countAll(); + assertTrue((q.countAll() == 10), () -> String.valueOf("Wrong amount: " + q.countAll())); } @ParameterizedTest @@ -81,7 +82,7 @@ public void testCountAllWhere(Morphium morphium) throws Exception { Query q = morphium.createQueryFor(UncachedObject.class); q.where("this.counter<100"); q.limit(1); - assert (q.countAll() == 10) : "Wrong amount: " + q.countAll(); + assertTrue((q.countAll() == 10), () -> String.valueOf("Wrong amount: " + q.countAll())); } @ParameterizedTest @@ -108,10 +109,10 @@ public void onOperationError(de.caluga.morphium.async.AsyncOperationType type, Q while (c.get() != 10) { Thread.sleep(100); - assert (System.currentTimeMillis() - s < morphium.getConfig().connectionSettings().getMaxWaitTime()); + assertTrue((System.currentTimeMillis() - s < morphium.getConfig().connectionSettings().getMaxWaitTime())); } - assert (c.get() == 10); + assertTrue((c.get() == 10)); } @ParameterizedTest diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QueryProjectionTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QueryProjectionTest.java index 78c08796d..16f511cf2 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QueryProjectionTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QueryProjectionTest.java @@ -10,6 +10,7 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("core") public class QueryProjectionTest extends MultiDriverTestBase { @@ -24,16 +25,16 @@ public void testSetProjection(Morphium morphium) throws Exception { while (morphium.createQueryFor(UncachedObject.class).f("_id").eq(uc.getMorphiumId()).countAll() == 0) { Thread.sleep(100); - assert (System.currentTimeMillis() - s < morphium.getConfig().connectionSettings().getMaxWaitTime()); + assertTrue((System.currentTimeMillis() - s < morphium.getConfig().connectionSettings().getMaxWaitTime())); } Thread.sleep(150); List lst = morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.counter).eq(2) .setProjection(UncachedObject.Fields.counter, UncachedObject.Fields.dval).asList(); assertEquals(lst.size(), 1); - assert (lst.get(0).getStrValue() == null); - assert (lst.get(0).getDval() != 0); - assert (lst.get(0).getCounter() != 0); + assertTrue((lst.get(0).getStrValue() == null)); + assertTrue((lst.get(0).getDval() != 0)); + assertTrue((lst.get(0).getCounter() != 0)); } @ParameterizedTest @@ -49,14 +50,14 @@ public void testAddProjection2(Morphium morphium) throws Exception { while (lst.size() == 0) { Thread.sleep(100); - assert (System.currentTimeMillis() - s < morphium.getConfig().connectionSettings().getMaxWaitTime()); + assertTrue((System.currentTimeMillis() - s < morphium.getConfig().connectionSettings().getMaxWaitTime())); lst = q.asList(); } assertEquals(lst.size(), 1, "Count wrong: " + lst.size() + " count is:" + q.countAll()); - assert (lst.get(0).getStrValue() == null); - assert (lst.get(0).getDval() != 0); - assert (lst.get(0).getCounter() != 0); + assertTrue((lst.get(0).getStrValue() == null)); + assertTrue((lst.get(0).getDval() != 0)); + assertTrue((lst.get(0).getCounter() != 0)); } @ParameterizedTest @@ -69,10 +70,10 @@ public void testHideFieldInProjection(Morphium morphium) throws Exception { while (lst.size() < 1) { lst = morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.counter).eq(2).hideFieldInProjection(UncachedObject.Fields.strValue).asList(); Thread.sleep(50); - assert (System.currentTimeMillis() - s < morphium.getConfig().connectionSettings().getMaxWaitTime()); + assertTrue((System.currentTimeMillis() - s < morphium.getConfig().connectionSettings().getMaxWaitTime())); } assertEquals(lst.size(), 1); - assert (lst.get(0).getStrValue() == null); + assertTrue((lst.get(0).getStrValue() == null)); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QuerySortPagingTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QuerySortPagingTest.java index 9ecc28b06..7e1bfb764 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QuerySortPagingTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QuerySortPagingTest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.params.provider.MethodSource; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("core") public class QuerySortPagingTest extends MultiDriverTestBase { @@ -18,7 +19,7 @@ public class QuerySortPagingTest extends MultiDriverTestBase { public void testLimit(Morphium morphium) { Query q = morphium.createQueryFor(UncachedObject.class); q.limit(10); - assert (q.getLimit() == 10); + assertTrue((q.getLimit() == 10)); } @ParameterizedTest @@ -26,7 +27,7 @@ public void testLimit(Morphium morphium) { public void testSkip(Morphium morphium) { Query q = morphium.createQueryFor(UncachedObject.class); q.skip(10); - assert (q.getSkip() == 10); + assertTrue((q.getSkip() == 10)); } @ParameterizedTest @@ -36,14 +37,14 @@ public void testSort(Morphium morphium) { q.sort(UncachedObject.Fields.counter, UncachedObject.Fields.strValue); assertNotNull(q.getSort()); ; - assert (q.getSort().get("counter").equals(Integer.valueOf(1))); - assert (q.getSort().get("str_value").equals(Integer.valueOf(1))); + assertTrue((q.getSort().get("counter").equals(Integer.valueOf(1)))); + assertTrue((q.getSort().get("str_value").equals(Integer.valueOf(1)))); int cnt = 0; for (String s : q.getSort().keySet()) { - assert (cnt < 2); - assert cnt != 0 || (s.equals("counter")); - assert cnt != 1 || (s.equals("str_value")); + assertTrue((cnt < 2)); + assertTrue(cnt != 0 || (s.equals("counter"))); + assertTrue(cnt != 1 || (s.equals("str_value"))); cnt++; } } @@ -55,14 +56,14 @@ public void testSortEnum(Morphium morphium) { q.sortEnum(UtilsMap.of((Enum) UncachedObject.Fields.counter, -1, UncachedObject.Fields.strValue, 1)); assertNotNull(q.getSort()); ; - assert (q.getSort().get("counter").equals(Integer.valueOf(-1))); - assert (q.getSort().get("str_value").equals(Integer.valueOf(1))); + assertTrue((q.getSort().get("counter").equals(Integer.valueOf(-1)))); + assertTrue((q.getSort().get("str_value").equals(Integer.valueOf(1)))); int cnt = 0; for (String s : q.getSort().keySet()) { - assert (cnt < 2); - assert cnt == 0 || (s.equals("counter")); - assert cnt == 1 || (s.equals("str_value")); + assertTrue((cnt < 2)); + assertTrue(cnt == 0 || (s.equals("counter"))); + assertTrue(cnt == 1 || (s.equals("str_value"))); cnt++; } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QuerySubDocsTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QuerySubDocsTest.java index bef9aedc4..f049e6cd9 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QuerySubDocsTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QuerySubDocsTest.java @@ -14,6 +14,7 @@ import java.util.Map; import static de.caluga.test.mongo.suite.base.TestUtils.waitForConditionToBecomeTrue; +import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("core") public class QuerySubDocsTest extends MultiDriverTestBase { @@ -34,7 +35,7 @@ public void testSubDocs(Morphium morphium) throws Exception { SubDocTest result = q.get(); return result != null && result.subDocs != null && result.subDocs.size() != 0; }); - assert (q.get().subDocs.size() != 0); + assertTrue((q.get().subDocs.size() != 0)); } @Entity diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QueryUpdateOperatorsTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QueryUpdateOperatorsTest.java index 51c7e7fe2..5836d90cb 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QueryUpdateOperatorsTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/QueryUpdateOperatorsTest.java @@ -24,57 +24,48 @@ public class QueryUpdateOperatorsTest extends MultiDriverTestBase { @MethodSource("getMorphiumInstancesNoSingle") public void testSet(Morphium morphium) throws Exception { createUncachedObjects(morphium, 100); - Thread.sleep(100); morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.counter).eq(42).set(UncachedObject.Fields.strValue, "changed", false, false, null); - Thread.sleep(50); - List lst = morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.strValue).eq("changed").asList(); - assertEquals(lst.size(), 1); + TestUtils.waitForConditionToBecomeTrue(5000, "Set not visible", () -> + morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.strValue).eq("changed").countAll() == 1); } @ParameterizedTest @MethodSource("getMorphiumInstancesNoSingle") public void testSetEnum(Morphium morphium) throws Exception { createUncachedObjects(morphium, 100); - Thread.sleep(100); Map m = new HashMap<>(); m.put(UncachedObject.Fields.strValue, "changed"); morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.counter).eq(42).setEnum(m, false, false); - Thread.sleep(50); - List lst = morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.strValue).eq("changed").asList(); - assertEquals(lst.size(), 1); + TestUtils.waitForConditionToBecomeTrue(5000, "SetEnum not visible", () -> + morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.strValue).eq("changed").countAll() == 1); } @ParameterizedTest @MethodSource("getMorphiumInstancesNoSingle") public void testSetEnum2(Morphium morphium) throws Exception { createUncachedObjects(morphium, 100); - Thread.sleep(100); Map m = new HashMap<>(); m.put(UncachedObject.Fields.strValue, "changed"); morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.counter).lt(3).setEnum(m, false, true); - Thread.sleep(50); - List lst = morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.strValue).eq("changed").asList(); - assertEquals(3, lst.size()); + TestUtils.waitForConditionToBecomeTrue(5000, "SetEnum multiple not visible", () -> + morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.strValue).eq("changed").countAll() == 3); } @ParameterizedTest @MethodSource("getMorphiumInstancesNoSingle") public void testSetEnum3(Morphium morphium) throws Exception { createUncachedObjects(morphium, 100); - Thread.sleep(200); Map m = new HashMap<>(); m.put(UncachedObject.Fields.strValue, "changed"); morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.counter).gt(1000).f(UncachedObject.Fields.counter).lt(1002).setEnum(m, true, true); - Thread.sleep(50); - List lst = morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.strValue).eq("changed").asList(); - assertEquals(lst.size(), 1); + TestUtils.waitForConditionToBecomeTrue(5000, "Upserted setEnum not visible", () -> + morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.strValue).eq("changed").countAll() == 1); } @ParameterizedTest @MethodSource("getMorphiumInstancesNoSingle") public void testSetEnumAsync(Morphium morphium) throws Exception { createUncachedObjects(morphium, 100); - Thread.sleep(500); Map m = new HashMap<>(); m.put(UncachedObject.Fields.strValue, "changed"); AtomicLong cnt = new AtomicLong(0); @@ -91,13 +82,9 @@ public void onOperationError(AsyncOperationType type, Query q, l } }); - while (cnt.get() == 0) { - Thread.yield(); - } - - Thread.sleep(100); - List lst = morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.strValue).eq("changed").asList(); - assertEquals(lst.size(), 1); + TestUtils.waitForConditionToBecomeTrue(10000, "Async setEnum callback not called", () -> cnt.get() > 0); + TestUtils.waitForConditionToBecomeTrue(5000, "Async setEnum not visible", () -> + morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.strValue).eq("changed").countAll() == 1); } @ParameterizedTest @@ -111,31 +98,29 @@ public void testSetUpsert(Morphium morphium) throws Exception { () -> !morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.strValue).eq("new").asList().isEmpty()); List lst = morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.strValue).eq("new").asList(); assertEquals(lst.size(), 1); - assert (lst.get(0).getCounter() == 10002); + assertTrue((lst.get(0).getCounter() == 10002)); } @ParameterizedTest @MethodSource("getMorphiumInstancesNoSingle") public void testSet2(Morphium morphium) throws Exception { createUncachedObjects(morphium, 100); - Thread.sleep(50); Map m = new HashMap<>(); m.put(UncachedObject.Fields.strValue.name(), "changed"); morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.counter).lt(2).set(m); - Thread.sleep(150); - List lst = morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.strValue).eq("changed").asList(); - assertEquals(lst.size(), 1); + TestUtils.waitForConditionToBecomeTrue(5000, "Set via map not visible", () -> + morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.strValue).eq("changed").countAll() == 1); } @ParameterizedTest @MethodSource("getMorphiumInstancesNoSingle") public void testSet3(Morphium morphium) throws Exception { createUncachedObjects(morphium, 100); - Thread.sleep(100); Map m = new HashMap<>(); m.put(UncachedObject.Fields.strValue.name(), "new"); morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.counter).eq(10002).set(m, true, true, null); - Thread.sleep(250); + TestUtils.waitForConditionToBecomeTrue(5000, "Upserted object not visible", () -> + morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.strValue).eq("new").countAll() == 1); List lst = morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.strValue).eq("new").asList(); assertEquals(1, lst.size()); assertEquals(10002, lst.get(0).getCounter()); @@ -150,7 +135,8 @@ public void testPush(Morphium morphium) throws Exception { TestUtils.waitForConditionToBecomeTrue(morphium.getConfig().connectionSettings().getMaxWaitTime(), "Did not store?", () -> morphium.createQueryFor(UncachedObject.class).f("_id").eq(uc.getMorphiumId()).countAll() == 1); morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.morphiumId).eq(uc.getMorphiumId()) .push(UncachedObject.Fields.intData, 42); - Thread.sleep(500); + TestUtils.waitForConditionToBecomeTrue(5000, "Push not visible", () -> + morphium.createQueryFor(UncachedObject.class).f("_id").eq(uc.getMorphiumId()).f(UncachedObject.Fields.intData).eq(42).countAll() == 1); morphium.reread(uc); assertNotNull(uc.getIntData()); assertEquals(42, uc.getIntData()[0]); @@ -165,7 +151,8 @@ public void testPushAll(Morphium morphium) throws Exception { TestUtils.waitForConditionToBecomeTrue(morphium.getConfig().connectionSettings().getMaxWaitTime(), "Did not store?", () -> morphium.createQueryFor(UncachedObject.class).f("_id").eq(uc.getMorphiumId()).countAll() == 1); List lst = Arrays.asList(42, 123); morphium.createQueryFor(UncachedObject.class).f("_id").eq(uc.getMorphiumId()).pushAll(UncachedObject.Fields.intData, lst); - Thread.sleep(500); + TestUtils.waitForConditionToBecomeTrue(5000, "PushAll not visible", () -> + morphium.createQueryFor(UncachedObject.class).f("_id").eq(uc.getMorphiumId()).f(UncachedObject.Fields.intData).eq(123).countAll() == 1); morphium.reread(uc); assertNotNull(uc.getIntData()); assertEquals(42, uc.getIntData()[0]); @@ -180,7 +167,8 @@ public void testPull(Morphium morphium) throws Exception { morphium.store(uc); TestUtils.waitForConditionToBecomeTrue(morphium.getConfig().connectionSettings().getMaxWaitTime(), "Did not store?", () -> morphium.createQueryFor(UncachedObject.class).f("_id").eq(uc.getMorphiumId()).countAll() == 1); morphium.createQueryFor(UncachedObject.class).f("_id").eq(uc.getMorphiumId()).pull(UncachedObject.Fields.intData, 12, false, false, null); - Thread.sleep(100); + TestUtils.waitForConditionToBecomeTrue(5000, "Pull not visible", () -> + morphium.createQueryFor(UncachedObject.class).f("_id").eq(uc.getMorphiumId()).f(UncachedObject.Fields.intData).eq(12).countAll() == 0); morphium.reread(uc); assertEquals(3, uc.getIntData().length); assertEquals(23, uc.getIntData()[0]); @@ -190,60 +178,39 @@ public void testPull(Morphium morphium) throws Exception { @MethodSource("getMorphiumInstancesNoSingle") public void testInc(Morphium morphium) throws Exception { createUncachedObjects(morphium, 10); - Thread.sleep(50); morphium.createQueryFor(UncachedObject.class) .f(UncachedObject.Fields.counter).lt(5) .inc(UncachedObject.Fields.counter, 100); - Thread.sleep(50); - long cnt = morphium.createQueryFor(UncachedObject.class) - .f(UncachedObject.Fields.counter).gte(100).countAll(); - assertNotEquals(0, cnt); - assertEquals(1, cnt); + TestUtils.waitForConditionToBecomeTrue(5000, "Inc not visible", () -> + morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.counter).gte(100).countAll() == 1); } @ParameterizedTest @MethodSource("getMorphiumInstancesNoSingle") public void testInc2(Morphium morphium) throws Exception { createUncachedObjects(morphium, 10); - Thread.sleep(50); morphium.createQueryFor(UncachedObject.class) .f(UncachedObject.Fields.counter).lt(5) .inc(UncachedObject.Fields.counter, 100, false, true); - Thread.sleep(50); - long cnt = morphium.createQueryFor(UncachedObject.class) - .f(UncachedObject.Fields.counter).gte(100).countAll(); - long s = System.currentTimeMillis(); - - while (cnt == 0) { - cnt = morphium.createQueryFor(UncachedObject.class) - .f(UncachedObject.Fields.counter).gte(100).countAll(); - assert (System.currentTimeMillis() - s < morphium.getConfig().connectionSettings().getMaxWaitTime()); - } - - assertNotEquals(0, cnt); - assertEquals(5, cnt); + TestUtils.waitForConditionToBecomeTrue(5000, "Multiple inc not visible", () -> + morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.counter).gte(100).countAll() == 5); } @ParameterizedTest @MethodSource("getMorphiumInstancesNoSingle") public void testInc3(Morphium morphium) throws Exception { createUncachedObjects(morphium, 10); - Thread.sleep(250); morphium.createQueryFor(UncachedObject.class) .f(UncachedObject.Fields.counter).lt(5) .inc(UncachedObject.Fields.dval, 0.2, false, true); - Thread.sleep(550); - long cnt = morphium.createQueryFor(UncachedObject.class) - .f(UncachedObject.Fields.dval).eq(0.2).countAll(); - assertNotEquals(0, cnt); - assertEquals(5, cnt); + TestUtils.waitForConditionToBecomeTrue(5000, "Inc on dval not visible", () -> + morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.dval).eq(0.2).countAll() == 5); } @ParameterizedTest @MethodSource("getMorphiumInstancesNoSingle") public void testIncAsync(Morphium morphium) throws Exception { createUncachedObjects(morphium, 10); - Thread.sleep(250); AtomicInteger ai = new AtomicInteger(0); morphium.createQueryFor(UncachedObject.class) .f(UncachedObject.Fields.counter).lt(5) @@ -268,36 +235,28 @@ public void onOperationSucceeded(AsyncOperationType type, Query @MethodSource("getMorphiumInstancesNoSingle") public void testDec(Morphium morphium) throws Exception { createUncachedObjects(morphium, 10); - Thread.sleep(50); morphium.createQueryFor(UncachedObject.class) .f(UncachedObject.Fields.counter).lt(5) .dec(UncachedObject.Fields.counter, 100); - Thread.sleep(550); - long cnt = morphium.createQueryFor(UncachedObject.class) - .f(UncachedObject.Fields.counter).lt(0).countAll(); - assertEquals(cnt, 1); + TestUtils.waitForConditionToBecomeTrue(5000, "Dec not visible", () -> + morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.counter).lt(0).countAll() == 1); } @ParameterizedTest @MethodSource("getMorphiumInstancesNoSingle") public void testDec2(Morphium morphium) throws Exception { createUncachedObjects(morphium, 10); - Thread.sleep(150); morphium.createQueryFor(UncachedObject.class) .f(UncachedObject.Fields.counter).lt(5) .dec(UncachedObject.Fields.counter, 100, false, true); - Thread.sleep(150); - long cnt = morphium.createQueryFor(UncachedObject.class) - .f(UncachedObject.Fields.counter).lt(0).countAll(); - assertNotEquals(0, cnt); - assertEquals(5, cnt); + TestUtils.waitForConditionToBecomeTrue(5000, "Multiple dec not visible", () -> + morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.counter).lt(0).countAll() == 5); } @ParameterizedTest @MethodSource("getMorphiumInstancesNoSingle") public void testDec3(Morphium morphium) throws Exception { createUncachedObjects(morphium, 10); - Thread.sleep(50); morphium.createQueryFor(UncachedObject.class) .f(UncachedObject.Fields.counter).lt(5) .dec(UncachedObject.Fields.dval, 0.2, false, true); @@ -310,20 +269,7 @@ public void testDec3(Morphium morphium) throws Exception { @MethodSource("getMorphiumInstancesNoSingle") public void testDecAsync(Morphium morphium) throws Exception { createUncachedObjects(morphium, 10); - long s = System.currentTimeMillis(); - - TestUtils.waitForConditionToBecomeTrue((long) morphium.getConfig().connectionSettings().getMaxWaitTime(), (dur, e) -> { - log.info("Took to long"); - }, () -> TestUtils.countUC(morphium) >= 10, (dur) -> { - log.info("waiting"); - }, (dur) -> { - log.info("Got all"); - }); - while (TestUtils.countUC(morphium) < 10) { - Thread.sleep(50); - assert (System.currentTimeMillis() - s < morphium.getConfig().connectionSettings().getMaxWaitTime()); - } - + TestUtils.waitForConditionToBecomeTrue((long) morphium.getConfig().connectionSettings().getMaxWaitTime(), "Objects not stored", () -> TestUtils.countUC(morphium) >= 10); AtomicInteger ai = new AtomicInteger(0); morphium.createQueryFor(UncachedObject.class) .f(UncachedObject.Fields.counter).lt(5) @@ -372,7 +318,10 @@ public void testSetWithArrayFilters(Morphium morphium) throws Exception { .setArrayFilters(de.caluga.morphium.driver.Doc.of("elem", de.caluga.morphium.driver.Doc.of("$gte", 90))); q.set(longListPath(morphium), 100L, false, false); TestUtils.waitForConditionToBecomeTrue(5000, "arrayFilters $set not applied", - () -> List.of(85L, 100L, 100L).equals(lcQuery(morphium).get().getLongList())); + () -> { + var r = lcQuery(morphium).get(); + return r != null && List.of(85L, 100L, 100L).equals(r.getLongList()); + }); } } @@ -385,7 +334,10 @@ public void testIncWithArrayFilters(Morphium morphium) throws Exception { .setArrayFilters(de.caluga.morphium.driver.Doc.of("elem", de.caluga.morphium.driver.Doc.of("$gte", 90))); q.inc(longListPath(morphium), 5, false, false); TestUtils.waitForConditionToBecomeTrue(5000, "arrayFilters $inc not applied", - () -> List.of(85L, 97L, 95L).equals(lcQuery(morphium).get().getLongList())); + () -> { + var r = lcQuery(morphium).get(); + return r != null && List.of(85L, 97L, 95L).equals(r.getLongList()); + }); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ReferenceTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ReferenceTest.java index 76e1ff576..6c6866c17 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ReferenceTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ReferenceTest.java @@ -21,6 +21,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -100,29 +101,29 @@ public void storeReferenceTest(Morphium morphium) throws InterruptedException { Query q = morphium.createQueryFor(ReferenceContainer.class); q.f("uc").eq(uc1); ReferenceContainer rcRead = q.get(); //should only be one... - assert(rcRead.getId().equals(rc.getId())) : "ID's different?!?!?"; - assert(rcRead.getUc().getMorphiumId().equals(rc.getUc().getMorphiumId())) : "Uc's Id's different?!?!"; - assert(rcRead.getCo().getId().equals(rc.getCo().getId())) : "Co's id's different"; - assert(rcRead.getLazyUc().getMorphiumId().equals(rc.getLazyUc().getMorphiumId())) : "lazy refs Ids differ"; - assert(rcRead.getLst().size() == rc.getLst().size()) : "Size of lists differ?"; - assert(rcRead.getLzyLst().get(0) instanceof MorphiumProxyMarker) : "List not lazy?"; - assert(rcRead.getLzyLst().get(0).getCounter() == rc.getLzyLst().get(0).getCounter()) : "Counter different?!?"; + assertTrue((rcRead.getId().equals(rc.getId())), "ID's different?!?!?"); + assertTrue((rcRead.getUc().getMorphiumId().equals(rc.getUc().getMorphiumId())), "Uc's Id's different?!?!"); + assertTrue((rcRead.getCo().getId().equals(rc.getCo().getId())), "Co's id's different"); + assertTrue((rcRead.getLazyUc().getMorphiumId().equals(rc.getLazyUc().getMorphiumId())), "lazy refs Ids differ"); + assertTrue((rcRead.getLst().size() == rc.getLst().size()), "Size of lists differ?"); + assertTrue((rcRead.getLzyLst().get(0) instanceof MorphiumProxyMarker), "List not lazy?"); + assertTrue((rcRead.getLzyLst().get(0).getCounter() == rc.getLzyLst().get(0).getCounter()), "Counter different?!?"); q = morphium.createQueryFor(ReferenceContainer.class).f("lst").eq(toSearchFor); rcRead = q.get(); assertNotNull(rcRead); ; - assert(rcRead.getUc().getCounter() != (toSearchFor != null ? toSearchFor.getCounter() : 0)); + assertTrue((rcRead.getUc().getCounter() != (toSearchFor != null ? toSearchFor.getCounter() : 0))); assertNotNull(rcRead.getCo()); ; - assert(rcRead.getId().equals(rc.getId())); + assertTrue((rcRead.getId().equals(rc.getId()))); q = morphium.createQueryFor(ReferenceContainer.class).f("lzyLst").eq(toSearchFor2); rcRead = q.get(); assertNotNull(rcRead); ; - assert(rcRead.getUc().getCounter() != (toSearchFor2 != null ? toSearchFor2.getCounter() : 0)); + assertTrue((rcRead.getUc().getCounter() != (toSearchFor2 != null ? toSearchFor2.getCounter() : 0))); assertNotNull(rcRead.getCo()); ; - assert(rcRead.getId().equals(rc.getId())); + assertTrue((rcRead.getId().equals(rc.getId()))); } @ParameterizedTest @@ -145,12 +146,12 @@ public void backwardCompatibilityTest(Morphium morphium) throws Exception { cmd.execute(); cmd.releaseConnection(); Thread.sleep(1000); - assert(morphium.createQueryFor(ReferenceContainer.class).countAll() == 1); + assertTrue((morphium.createQueryFor(ReferenceContainer.class).countAll() == 1)); ReferenceContainer container = morphium.createQueryFor(ReferenceContainer.class).get(); assertNotNull(container.uc); ; - assert(container.uc.getMorphiumId().equals(referenced.getMorphiumId())); - assert(container.uc.getCounter() == referenced.getCounter()); + assertTrue((container.uc.getMorphiumId().equals(referenced.getMorphiumId()))); + assertTrue((container.uc.getCounter() == referenced.getCounter())); } @@ -177,8 +178,8 @@ public void testSimpleDoublyLinkedStructure(Morphium morphium) throws Interrupte Thread.sleep(100); e2 = m.findById(SimpleDoublyLinkedEntity.class, e2.id); e1 = m.findById(SimpleDoublyLinkedEntity.class, e1.id); - assert(e1.getValue() == e2.getPrev().getValue()); - assert(e2.getValue() == e1.getNext().getValue()); + assertTrue((e1.getValue() == e2.getPrev().getValue())); + assertTrue((e2.getValue() == e1.getNext().getValue())); } @@ -200,12 +201,12 @@ public void mapReferenceTest(Morphium morphium) throws Exception { morphium.store(c); Thread.sleep(150); ReferenceContainer cont = morphium.createQueryFor(ReferenceContainer.class).get(); - assert(cont.id.equals(c.id)); + assertTrue((cont.id.equals(c.id))); for (int i = 0; i < 10; i++) { - assert(cont.map.get("" + i).getCounter() == i); - assert(cont.map.get("" + i).getStrValue().equals("" + i)); - assert(cont.map.get("" + i).getMorphiumId().equals(c.map.get("" + i).getMorphiumId())); + assertTrue((cont.map.get("" + i).getCounter() == i)); + assertTrue((cont.map.get("" + i).getStrValue().equals("" + i))); + assertTrue((cont.map.get("" + i).getMorphiumId().equals(c.map.get("" + i).getMorphiumId()))); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/SetsTests.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/SetsTests.java index a6b45a92e..ca540206c 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/SetsTests.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/SetsTests.java @@ -21,6 +21,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; /** @@ -49,7 +50,7 @@ public void setStoringTest(Morphium morphium) throws Exception { morphium.storeList(lst); Thread.sleep(200); long count = morphium.createQueryFor(UncachedObject.class, "UCTest").countAll(); - assert(count == 100) : "Count wrong " + count; + assertTrue((count == 100), () -> String.valueOf("Count wrong " + count)); } @ParameterizedTest @@ -94,19 +95,19 @@ public void simpleSetTest(Morphium morphium) throws Exception { assertNotNull(lst2.getStringSet(), "String list null?"); for (int i = 0; i < count; i++) { - assert(lst2.getEmbeddedObjectsSet().toArray()[i].equals(lst.getEmbeddedObjectsSet().toArray()[i])) : "Embedded objects list differ? - " + i; - assert(lst2.getLongSet().toArray()[i].equals(lst.getLongSet().toArray()[i])) : "long list differ? - " + i; - assert(lst2.getStringSet().toArray()[i].equals(lst.getStringSet().toArray()[i])) : "string list differ? - " + i; - assert(lst2.getRefSet().toArray()[i].equals(lst.getRefSet().toArray()[i])) : "reference list differ? - " + i; + assertTrue((lst2.getEmbeddedObjectsSet().toArray()[i].equals(lst.getEmbeddedObjectsSet().toArray()[i])), String.valueOf("Embedded objects list differ? - " + i)); + assertTrue((lst2.getLongSet().toArray()[i].equals(lst.getLongSet().toArray()[i])), String.valueOf("long list differ? - " + i)); + assertTrue((lst2.getStringSet().toArray()[i].equals(lst.getStringSet().toArray()[i])), String.valueOf("string list differ? - " + i)); + assertTrue((lst2.getRefSet().toArray()[i].equals(lst.getRefSet().toArray()[i])), String.valueOf("reference list differ? - " + i)); } Thread.sleep(1000); q = morphium.createQueryFor(SetContainer.class).f("refSet").eq(lst2.getRefSet().toArray()[0]); - assert(q.countAll() != 0); + assertTrue((q.countAll() != 0)); log.info("found " + q.countAll() + " entries"); - assert(q.countAll() == 1); + assertTrue((q.countAll() == 1)); SetContainer c = q.get(); - assert(c.getId().equals(lst2.getId())); + assertTrue((c.getId().equals(lst2.getId()))); } @ParameterizedTest @@ -150,9 +151,9 @@ public void nullValueListTest(Morphium morphium) throws Exception { Query q = morphium.createQueryFor(SetContainer.class).f("id").eq(lst.getId()); q.setReadPreferenceLevel(ReadPreferenceLevel.PRIMARY); SetContainer lst2 = (SetContainer) q.get(); - assert(lst2.getStringSet().toArray()[count] == null); - assert(lst2.getRefSet().toArray()[count] == null); - assert(lst2.getEmbeddedObjectsSet().toArray()[count] == null); + assertTrue((lst2.getStringSet().toArray()[count] == null)); + assertTrue((lst2.getRefSet().toArray()[count] == null)); + assertTrue((lst2.getEmbeddedObjectsSet().toArray()[count] == null)); } @@ -171,7 +172,7 @@ public void singleEntryListTest(Morphium morphium) throws Exception { lst.toArray(new UncachedObject[] {})[0].setCounter(999); morphium.storeList(lst); Thread.sleep(100); - assert(morphium.createQueryFor(UncachedObject.class).asList().get(0).getCounter() == 999); + assertTrue((morphium.createQueryFor(UncachedObject.class).asList().get(0).getCounter() == 999)); } @@ -204,20 +205,20 @@ public void testHybridSet(Morphium morphium) throws InterruptedException { TestUtils.waitForConditionToBecomeTrue(15000, "Object not queryable", () -> morphium.findById(MySetContainer.class, expectedId) != null); MySetContainer mc2 = morphium.findById(MySetContainer.class, expectedId); - assert(mc2.id.equals(mc.id)); - assert(mc2.objectList.size() == mc.objectList.size()); - assert(mc2.objectList.toArray()[0] instanceof UncachedObject); - assert(mc2.objectList.toArray()[1] instanceof EmbeddedObject); - assert(mc2.objectList.toArray()[2] instanceof ExtendedEmbeddedObject); - assert(((UncachedObject) mc2.objectList.toArray()[0]).getStrValue().equals("val")); - assert(((UncachedObject) mc2.objectList.toArray()[0]).getCounter() == 42); - assert(((EmbeddedObject) mc2.objectList.toArray()[1]).getValue().equals("Embedded")); - assert(((EmbeddedObject) mc2.objectList.toArray()[1]).getName().equals("Fred")); - assert(((EmbeddedObject) mc2.objectList.toArray()[1]).getTest() != 0); - assert(((ExtendedEmbeddedObject) mc2.objectList.toArray()[2]).getName().equals("testName")); - assert(((ExtendedEmbeddedObject) mc2.objectList.toArray()[2]).getAdditionalValue().equals("additionalValue")); - assert(((ExtendedEmbeddedObject) mc2.objectList.toArray()[2]).getTest() == 4711); - assert(((ExtendedEmbeddedObject) mc2.objectList.toArray()[2]).getValue().equals("value")); + assertTrue((mc2.id.equals(mc.id))); + assertTrue((mc2.objectList.size() == mc.objectList.size())); + assertTrue((mc2.objectList.toArray()[0] instanceof UncachedObject)); + assertTrue((mc2.objectList.toArray()[1] instanceof EmbeddedObject)); + assertTrue((mc2.objectList.toArray()[2] instanceof ExtendedEmbeddedObject)); + assertTrue((((UncachedObject) mc2.objectList.toArray()[0]).getStrValue().equals("val"))); + assertTrue((((UncachedObject) mc2.objectList.toArray()[0]).getCounter() == 42)); + assertTrue((((EmbeddedObject) mc2.objectList.toArray()[1]).getValue().equals("Embedded"))); + assertTrue((((EmbeddedObject) mc2.objectList.toArray()[1]).getName().equals("Fred"))); + assertTrue((((EmbeddedObject) mc2.objectList.toArray()[1]).getTest() != 0)); + assertTrue((((ExtendedEmbeddedObject) mc2.objectList.toArray()[2]).getName().equals("testName"))); + assertTrue((((ExtendedEmbeddedObject) mc2.objectList.toArray()[2]).getAdditionalValue().equals("additionalValue"))); + assertTrue((((ExtendedEmbeddedObject) mc2.objectList.toArray()[2]).getTest() == 4711)); + assertTrue((((ExtendedEmbeddedObject) mc2.objectList.toArray()[2]).getValue().equals("value"))); } @ParameterizedTest @@ -243,13 +244,13 @@ public void idListTest(Morphium morphium) throws Exception { () -> morphium.findById(MyIdSetContainer.class, expectedId) != null); MyIdSetContainer ilst2 = morphium.findById(MyIdSetContainer.class, expectedId); assertNotNull(ilst2); - assert(ilst2.idList.size() == ilst.idList.size()); - assert(ilst2.idList.toArray()[0].equals(ilst.idList.toArray()[0])); + assertTrue((ilst2.idList.size() == ilst.idList.size())); + assertTrue((ilst2.idList.toArray()[0].equals(ilst.idList.toArray()[0]))); ilst2.idList.add(new MorphiumId()); ilst2.number = 234; morphium.store(ilst2); - assert(ilst2.idList.toArray()[0] instanceof MorphiumId); - assert(ilst2.idList.toArray()[0].equals(ilst.idList.toArray()[0])); + assertTrue((ilst2.idList.toArray()[0] instanceof MorphiumId)); + assertTrue((ilst2.idList.toArray()[0].equals(ilst.idList.toArray()[0]))); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ShardingTests.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ShardingTests.java index a8168baeb..73ecab6f2 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ShardingTests.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/ShardingTests.java @@ -94,13 +94,13 @@ public void shardingReplacementTest(Morphium morphium) throws Exception { uc.setStrValue("again"); morphium.store(uc, morphium.getMapper().getCollectionName(UncachedObject.class), null); morphium.reread(uc, morphium.getMapper().getCollectionName(UncachedObject.class)); - assert(uc.getStrValue().equals("again")); + assertTrue((uc.getStrValue().equals("again"))); uc = morphium.createQueryFor(UncachedObject.class).f(UncachedObject.Fields.counter).eq(42).get(); uc.setStrValue("another value"); morphium.store(uc, morphium.getMapper().getCollectionName(UncachedObject.class), null); Thread.sleep(100); morphium.reread(uc, morphium.getMapper().getCollectionName(UncachedObject.class)); - assert(uc.getStrValue().equals("another value")); + assertTrue((uc.getStrValue().equals("another value"))); } @ParameterizedTest @@ -148,7 +148,7 @@ public void shardingStringIdReplacementTest(Morphium morphium) throws Exception uc.value = "again"; morphium.store(uc, morphium.getMapper().getCollectionName(StringIdTestEntity.class), null); morphium.reread(uc, morphium.getMapper().getCollectionName(StringIdTestEntity.class)); - assert(uc.value.equals("again")); + assertTrue((uc.value.equals("again"))); uc = new StringIdTestEntity(); uc.value = "test123"; morphium.store(uc, morphium.getMapper().getCollectionName(StringIdTestEntity.class), null); @@ -157,7 +157,7 @@ public void shardingStringIdReplacementTest(Morphium morphium) throws Exception morphium.store(uc, morphium.getMapper().getCollectionName(StringIdTestEntity.class), null); Thread.sleep(100); morphium.reread(uc, morphium.getMapper().getCollectionName(StringIdTestEntity.class)); - assert(uc.value.equals("another value")); + assertTrue((uc.value.equals("another value"))); } @Entity diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/SortingTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/SortingTest.java index aa6a1f70a..7f90efd66 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/SortingTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/SortingTest.java @@ -16,6 +16,7 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -161,11 +162,11 @@ public void sortTestAscending(Morphium morphium) throws Exception { int lastValue = -1; for (UncachedObject u : lst) { - assert(lastValue <= u.getCounter()) : "Counter not greater, last: " + lastValue + " now:" + u.getCounter(); + assertTrue((lastValue <= u.getCounter()), String.valueOf("Counter not greater, last: " + lastValue + " now:" + u.getCounter())); lastValue = u.getCounter(); } - assert(lastValue == 7599) : "Last value wrong: " + lastValue; + assertTrue((lastValue == 7599), String.valueOf("Last value wrong: " + lastValue)); q = morphium.createQueryFor(UncachedObject.class); q = q.f("str_value").eq("Random value"); Map order = new HashMap<>(); @@ -175,11 +176,11 @@ public void sortTestAscending(Morphium morphium) throws Exception { lastValue = -1; for (UncachedObject u : lst) { - assert(lastValue <= u.getCounter()) : "Counter not smaller, last: " + lastValue + " now:" + u.getCounter(); + assertTrue((lastValue <= u.getCounter()), String.valueOf("Counter not smaller, last: " + lastValue + " now:" + u.getCounter())); lastValue = u.getCounter(); } - assert(lastValue == 7599) : "Last value wrong: " + lastValue; + assertTrue((lastValue == 7599), String.valueOf("Last value wrong: " + lastValue)); } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/StatisticsTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/StatisticsTest.java index 178fbb0d1..54cb6aa36 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/StatisticsTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/StatisticsTest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("core") public class StatisticsTest extends MultiDriverTestBase { @@ -33,13 +34,13 @@ public void putAll(Morphium morphium) { @ParameterizedTest @MethodSource("getMorphiumInstancesNoSingle") public void equalsTest(Morphium morphium) { - assert (!morphium.getStatistics().equals(UtilsMap.of("test", 0.2))); + assertTrue((!morphium.getStatistics().equals(UtilsMap.of("test", 0.2)))); } @ParameterizedTest @MethodSource("getMorphiumInstancesNoSingle") public void hashcodeTest(Morphium morphium) { - assert (morphium.getStatistics().hashCode() != 0); + assertTrue((morphium.getStatistics().hashCode() != 0)); } @ParameterizedTest diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/StatsTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/StatsTest.java index e6bd0d026..3248cbec8 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/StatsTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/StatsTest.java @@ -23,7 +23,7 @@ public void testDbStats(Morphium morphium) throws Exception { createUncachedObjects(morphium, 100); Thread.sleep(100); Map stats = morphium.getDbStats(); - assert (!stats.isEmpty()); + assertTrue((!stats.isEmpty())); for (String k : stats.keySet()) { log.info("Stat: " + k + " : " + stats.get(k)); } @@ -35,7 +35,7 @@ public void testCollStats(Morphium morphium) throws Exception { createUncachedObjects(morphium, 100); Thread.sleep(100); Map stats = morphium.getCollStats(UncachedObject.class); - assert (!stats.isEmpty()); + assertTrue((!stats.isEmpty())); for (String k : stats.keySet()) { log.info("Stat: " + k + " : " + stats.get(k)); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/SubDocumentTests.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/SubDocumentTests.java index 44ea18547..3557e8d78 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/SubDocumentTests.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/SubDocumentTests.java @@ -130,7 +130,7 @@ public void testSubDocAdditionals(Morphium morphium) throws Exception { while (lst.size() != 1) { Thread.sleep(100); lst = morphium.createQueryFor(SubDocumentAdditional.class).f("sub.val").eq(42).asList(); - assert (System.currentTimeMillis() - st < 5000); + assertTrue((System.currentTimeMillis() - st < 5000)); } assertEquals(1, lst.size()); assertNotNull(lst.get(0).additionals.get("sub")); diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/TypeIdTests.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/TypeIdTests.java index 776c5dd61..1e5d8defa 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/TypeIdTests.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/TypeIdTests.java @@ -35,9 +35,9 @@ public void testAdditionalDataEmbedded(Morphium morphium) throws Exception { ad.setAdditionals(null); AdditionalDataEntity adReread = TestUtils.waitForObject(() -> morphium.reread(ad)); assertNotNull(adReread.getAdditionals()); - assert(adReread.getAdditionals().containsKey("test")); - assert(adReread.getAdditionals().get("test") instanceof EmbeddedObject); - assert(((EmbeddedObject) adReread.getAdditionals().get("test")).getName().equals("name")); + assertTrue((adReread.getAdditionals().containsKey("test"))); + assertTrue((adReread.getAdditionals().get("test") instanceof EmbeddedObject)); + assertTrue((((EmbeddedObject) adReread.getAdditionals().get("test")).getName().equals("name"))); checkTypeId(morphium, EmbeddedObject.class, adReread, "test"); } @@ -55,9 +55,9 @@ public void testAdditionalDataEmbeddedUpdate(Morphium morphium) throws Exception ad = morphium.reread(ad); assertNotNull(ad.getAdditionals()); ; - assert(ad.getAdditionals().containsKey("test")); - assert(ad.getAdditionals().get("test") instanceof EmbeddedObject); - assert(((EmbeddedObject) ad.getAdditionals().get("test")).getName().equals("emb")); + assertTrue((ad.getAdditionals().containsKey("test"))); + assertTrue((ad.getAdditionals().get("test") instanceof EmbeddedObject)); + assertTrue((((EmbeddedObject) ad.getAdditionals().get("test")).getName().equals("emb"))); checkTypeId(morphium, EmbeddedObject.class, ad, "test"); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/UpdateTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/UpdateTest.java index f04ce2f89..37e13761f 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/UpdateTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/UpdateTest.java @@ -48,7 +48,8 @@ public void incMultipleFieldsTest(Morphium morphium) throws Exception { morphium.store(o); } - Thread.sleep(150); + TestUtils.waitForConditionToBecomeTrue(5000, "Did not write?", + () -> morphium.createQueryFor(UncachedMultipleCounter.class).countAll() == 50); Query q = morphium.createQueryFor(UncachedMultipleCounter.class); q = q.f("strValue").eq("Uncached " + 5); @@ -58,12 +59,18 @@ public void incMultipleFieldsTest(Morphium morphium) throws Exception { morphium.inc(q, toInc, false, true, null); final Query finalQ = q; // Capture for lambda TestUtils.waitForConditionToBecomeTrue(3000, "Counter increment to 15 not completed", - () -> finalQ.get().getCounter() == 15); - assert(q.get().getCounter2() == 3); + () -> { + var r = finalQ.get(); + return r != null && r.getCounter() == 15; + }); + assertTrue((q.get().getCounter2() == 3)); morphium.inc(q, toInc, false, true, null); TestUtils.waitForConditionToBecomeTrue(1000, "Counter increment to 25 not completed", - () -> finalQ.get().getCounter() == 25); - assert(q.get().getCounter2() == 3.5); + () -> { + var r = finalQ.get(); + return r != null && r.getCounter() == 25; + }); + assertTrue((q.get().getCounter2() == 3.5)); } } @@ -82,14 +89,14 @@ public void incTest(Morphium morphium) throws Exception { q = q.f("str_value").eq("Uncached " + 5); UncachedObject uc = q.get(); morphium.inc(uc, "counter", 1); - assert(uc.getCounter() == 6) : "Counter is not correct: " + uc.getCounter(); + assertTrue((uc.getCounter() == 6), () -> String.valueOf("Counter is not correct: " + uc.getCounter())); // inc without object - single update, no upsert q = morphium.createQueryFor(UncachedObject.class); q = q.f("counter").gte(10).f("counter").lte(25).sort("counter"); morphium.inc(q, "counter", 100); - Thread.sleep(100); - uc = q.get(); - assert(uc.getCounter() == 11) : "Counter is wrong: " + uc.getCounter(); + var q1 = q; + TestUtils.waitForConditionToBecomeTrue(5000, "Counter is wrong", + () -> q1.get() != null && q1.get().getCounter() == 11); // inc without object directly in DB - multiple update q = morphium.createQueryFor(UncachedObject.class); q = q.f("counter").gt(10).f("counter").lte(25); @@ -99,10 +106,7 @@ public void incTest(Morphium morphium) throws Exception { List lst = q.asList(); // read the data after update for (UncachedObject u : lst) { - assert(u.getCounter() > 110 - && u.getCounter() <= 125 - && u.getStrValue().equals("Uncached " + (u.getCounter() - 100))) - : "Counter wrong: " + u.getCounter(); + assertTrue((u.getCounter() > 110 && u.getCounter() <= 125 && u.getStrValue().equals("Uncached " + (u.getCounter() - 100))), () -> String.valueOf("Counter wrong: " + u.getCounter())); } } } @@ -118,13 +122,17 @@ public void decTest(Morphium morphium) throws Exception { morphium.store(o); } - Thread.sleep(150); + TestUtils.waitForConditionToBecomeTrue(5000, "Did not write?", ()->TestUtils.countUC(morphium) == 50); Query q = morphium.createQueryFor(UncachedObject.class); q = q.f("str_value").eq("Uncached " + 5); UncachedObject uc = q.get(); morphium.dec(uc, "counter", 1); - Thread.sleep(300); - assert(uc.getCounter() == 4) : "Counter is not correct: " + uc.getCounter(); + var uc1 = uc; + TestUtils.waitForConditionToBecomeTrue(5000, "Counter is not correct", + () -> { + UncachedObject r = morphium.reread(uc1); + return r != null && r.getCounter() == 4; + }); // inc without object - single update, no upsert q = morphium.createQueryFor(UncachedObject.class); q = q.f("counter").gte(40).f("counter").lte(55).sort("counter"); @@ -132,19 +140,19 @@ public void decTest(Morphium morphium) throws Exception { var q1 = q; TestUtils.waitForConditionToBecomeTrue(5000, "Object not found?!?!", ()->q1.get() != null); uc = q.get(); - assert(uc.getCounter() == 41) : "Counter is wrong: " + uc.getCounter(); + assertTrue((uc.getCounter() == 41), String.valueOf("Counter is wrong: " + uc.getCounter())); // inc without object directly in DB - multiple update q = morphium.createQueryFor(UncachedObject.class); q = q.f("counter").gt(40).f("counter").lte(55); morphium.dec(q, "counter", 40, false, true); - Thread.sleep(300); + var q2 = q; + TestUtils.waitForConditionToBecomeTrue(5000, "Multi dec not applied", ()->q2.countAll() == 0); q = morphium.createQueryFor(UncachedObject.class); q = q.f("counter").gt(0).f("counter").lte(55); List lst = q.asList(); // read the data after update for (UncachedObject u : lst) { - assert(u.getCounter() > 0 && u.getCounter() <= 55) - : "Counter wrong: " + u.getCounter(); + assertTrue((u.getCounter() > 0 && u.getCounter() <= 55), () -> String.valueOf("Counter wrong: " + u.getCounter())); // assert(u.getValue().equals("Uncached "+(u.getCounter()-40))):"Value // wrong: Counter: "+u.getCounter()+" Value;: "+u.getValue(); } @@ -162,7 +170,7 @@ public void setEntityTest(Morphium morphium) throws Exception { morphium.store(o); } - Thread.sleep(250); + TestUtils.waitForConditionToBecomeTrue(5000, "Did not write?", ()->TestUtils.countUC(morphium) == 50); Query q = morphium.createQueryFor(UncachedObject.class); q = q.f("counter").eq(42); UncachedObject uc = q.get(); @@ -181,12 +189,12 @@ public void setEntityTest(Morphium morphium) throws Exception { } private void checkValue(Morphium morphium, UncachedObject uc, String value) throws Exception { - Thread.sleep(100); - assert(uc.getStrValue().equals(value)) - : "Value wrong: " + uc.getStrValue() + " but should be " + value; - uc = morphium.reread(uc); - assert(uc.getStrValue().equals(value)) - : "Value after reread wrong: " + uc.getStrValue() + ", expected " + value; + assertTrue((uc.getStrValue().equals(value)), () -> String.valueOf("Value wrong: " + uc.getStrValue() + " but should be " + value)); + TestUtils.waitForConditionToBecomeTrue(5000, "Value after reread wrong", + () -> { + UncachedObject r = morphium.reread(uc); + return r != null && value.equals(r.getStrValue()); + }); } @ParameterizedTest @@ -200,14 +208,15 @@ public void setTest(Morphium morphium) throws Exception { morphium.store(o); } - Thread.sleep(100); + TestUtils.waitForConditionToBecomeTrue(5000, "Did not write?", ()->TestUtils.countUC(morphium) == 50); Query q = morphium.createQueryFor(UncachedObject.class); q = q.f("strValue").eq("unexistent"); q.set("counter", 999, true, false); - Thread.sleep(220); + var q1 = q; + TestUtils.waitForConditionToBecomeTrue(5000, "Upsert not visible", ()->q1.get() != null); UncachedObject uc = q.get(); // should now work assertNotNull(uc, "Not found?!?!?"); - assert(uc.getStrValue().equals("unexistent")) : "Value wrong: " + uc.getStrValue(); + assertTrue((uc.getStrValue().equals("unexistent")), () -> String.valueOf("Value wrong: " + uc.getStrValue())); } } @@ -237,11 +246,17 @@ public void addAllToSetTest(Morphium morphium) throws Exception { morphium.store(lc); } - Thread.sleep(150); + TestUtils.waitForConditionToBecomeTrue(5000, "Did not write?", + () -> morphium.createQueryFor(ListContainer.class).countAll() == 50); Query lc = morphium.createQueryFor(ListContainer.class); lc = lc.f("name").eq("LC15"); morphium.addAllToSet(lc, "long_list", Arrays.asList(12345L, 12345L, 123L, 42L), true); - Thread.sleep(100); + var lc1 = lc; + TestUtils.waitForConditionToBecomeTrue(5000, "addAllToSet not applied", + () -> { + var r = lc1.get(); + return r != null && r.getLongList().size() == 4; + }); ListContainer cont = lc.get(); assertTrue(cont.getLongList().contains(12345L)); assertEquals(cont.getLongList().size(), 4); @@ -262,12 +277,18 @@ public void addToSetTest(Morphium morphium) throws Exception { morphium.store(lc); } - Thread.sleep(150); + TestUtils.waitForConditionToBecomeTrue(5000, "Did not write?", + () -> morphium.createQueryFor(ListContainer.class).countAll() == 50); Query lc = morphium.createQueryFor(ListContainer.class); lc = lc.f("name").eq("LC15"); morphium.addToSet(lc, "long_list", 12345L); morphium.addToSet(lc, "long_list", 12345L); - Thread.sleep(100); + var lc1 = lc; + TestUtils.waitForConditionToBecomeTrue(5000, "addToSet not applied", + () -> { + var r = lc1.get(); + return r != null && r.getLongList().size() == 2; + }); ListContainer cont = lc.get(); assertTrue(cont.getLongList().contains(12345L)); assertEquals(cont.getLongList().size(), 2); @@ -288,12 +309,17 @@ public void pushTest(Morphium morphium) throws Exception { morphium.store(lc); } - Thread.sleep(150); + TestUtils.waitForConditionToBecomeTrue(5000, "Did not write?", + () -> morphium.createQueryFor(ListContainer.class).countAll() == 50); Query lc = morphium.createQueryFor(ListContainer.class); lc = lc.f("name").eq("LC15"); morphium.push(lc, "long_list", 12345L); - ListContainer cont = lc.get(); - assert(cont.getLongList().contains(12345L)) : "No push?"; + var lc1 = lc; + TestUtils.waitForConditionToBecomeTrue(5000, "No push?", + () -> { + var r = lc1.get(); + return r != null && r.getLongList().contains(12345L); + }); } } @@ -311,7 +337,8 @@ public void pushEntityTest(Morphium morphium) throws Exception { morphium.store(lc); } - Thread.sleep(150); + TestUtils.waitForConditionToBecomeTrue(5000, "Did not write?", + () -> morphium.createQueryFor(ListContainer.class).countAll() == 50); Query lc = morphium.createQueryFor(ListContainer.class); lc = lc.f("name").eq("LC15"); EmbeddedObject em = new EmbeddedObject(); @@ -326,8 +353,8 @@ public void pushEntityTest(Morphium morphium) throws Exception { ListContainer lc2 = lc.get(); assertNotNull(lc2.getEmbeddedObjectList()); ; - assert(lc2.getEmbeddedObjectList().size() == 2); - assert(lc2.getEmbeddedObjectList().get(0).getTest() == 1L); + assertTrue((lc2.getEmbeddedObjectList().size() == 2)); + assertTrue((lc2.getEmbeddedObjectList().get(0).getTest() == 1L)); } } @@ -340,31 +367,37 @@ public void unsetTest(Morphium morphium) throws Exception { morphium.createQueryFor(UncachedObject.class).f("counter").eq(50); // morphium.unsetQ(q, "strValue"); q.unset( "strValue"); - Thread.sleep(300); - UncachedObject uc = q.get(); - assert(uc.getStrValue() == null); + var q1 = q; + TestUtils.waitForConditionToBecomeTrue(5000, "strValue not unset", + () -> { + var r = q1.get(); + return r != null && r.getStrValue() == null; + }); q = morphium.createQueryFor(UncachedObject.class).f("counter").gt(90); q.unset(false, "str_value"); - Thread.sleep(300); + var q2 = q; + TestUtils.waitForConditionToBecomeTrue(5000, "Single unset not applied", + () -> q2.asList().stream().filter(u -> u.getStrValue() == null).count() == 1); List lst = q.asList(); boolean found = false; for (UncachedObject u : lst) { if (u.getStrValue() == null) { - assert(!found); + assertTrue((!found)); found = true; } } - assert(found); + assertTrue((found)); // morphium.unsetQ(q, true, "binary_data", "bool_data", "str_value"); q.unset(true, "binary_data", "bool_data", "str_value"); - Thread.sleep(300); + TestUtils.waitForConditionToBecomeTrue(5000, "Multi unset not applied", + () -> q2.asList().stream().allMatch(u -> u.getStrValue() == null)); lst = q.asList(); for (UncachedObject u : lst) { - assert(u.getStrValue() == null); + assertTrue((u.getStrValue() == null)); } } } @@ -383,7 +416,8 @@ public void pushEntityListTest(Morphium morphium) throws Exception { morphium.store(lc); } - Thread.sleep(150); + TestUtils.waitForConditionToBecomeTrue(5000, "Did not write?", + () -> morphium.createQueryFor(ListContainer.class).countAll() == 50); List obj = new ArrayList<>(); Query lc = morphium.createQueryFor(ListContainer.class); lc = lc.f("name").eq("LC15"); @@ -401,13 +435,17 @@ public void pushEntityListTest(Morphium morphium) throws Exception { obj.add(em); morphium.pushAll(lc, "embedded_object_list", obj, false, true); TestUtils.waitForWrites(morphium, log); - Thread.sleep(2500); + var lc1 = lc; + TestUtils.waitForConditionToBecomeTrue(5000, "pushAll not applied", + () -> { + ListContainer r = lc1.get(); + return r != null && r.getEmbeddedObjectList() != null && r.getEmbeddedObjectList().size() == 3; + }); ListContainer lc2 = lc.get(); assertNotNull(lc2.getEmbeddedObjectList()); ; - assert(lc2.getEmbeddedObjectList().size() == 3) - : "Size wrong, should be 3 is " + lc2.getEmbeddedObjectList().size(); - assert(lc2.getEmbeddedObjectList().get(0).getTest() == 1L); + assertTrue((lc2.getEmbeddedObjectList().size() == 3), () -> String.valueOf("Size wrong, should be 3 is " + lc2.getEmbeddedObjectList().size())); + assertTrue((lc2.getEmbeddedObjectList().get(0).getTest() == 1L)); } } @@ -417,19 +455,21 @@ public void updateUsingFieldsTest(Morphium morphium) throws Exception { try (morphium) { UncachedObject uc = new UncachedObject("value", 1001); morphium.store(uc); - Thread.sleep(100); + TestUtils.waitForConditionToBecomeTrue(5000, "Object not stored", + () -> morphium.findById(UncachedObject.class, uc.getMorphiumId()) != null); uc.setStrValue("new Value"); uc.setCounter(0); uc.setDval(4.0d); uc.setLongData(new long[] {42l}); morphium.updateUsingFields(uc, "str_value", "longData"); - Thread.sleep(100); + TestUtils.waitForConditionToBecomeTrue(5000, "Update not applied", + () -> "new Value".equals(morphium.findById(UncachedObject.class, uc.getMorphiumId()).getStrValue())); UncachedObject uc2 = morphium.findById(UncachedObject.class, uc.getMorphiumId()); - assert(uc2.getCounter() == 1001); + assertTrue((uc2.getCounter() == 1001)); assertNotNull(uc2.getLongData()); ; - assert(uc2.getLongData()[0] == 42); - assert(uc2.getDval() == 0); + assertTrue((uc2.getLongData()[0] == 42)); + assertTrue((uc2.getDval() == 0)); } } @@ -458,7 +498,6 @@ public void updateLimitTest(Morphium morphium) throws Exception { var ret = q.set(UncachedObject.Fields.strValue, "not all updated", false, true); log.info(Utils.toJsonString(ret)); var chk2 = q.q().f("counter").gte(900).f("counter").lt(950).f("str_value").eq("not all updated"); - Thread.sleep(1000); log.info("Updated: " + chk2.countAll()); TestUtils.waitForConditionToBecomeTrue(5000, "Update failed!", ()->chk2.countAll() == 5); lst = q.q().f("counter").gte(900).f("counter").lt(950).asList(); @@ -478,7 +517,7 @@ public void updateProperty(Morphium morphium) throws Exception { uc.theString = "not set"; morphium.store(uc); morphium.reread(uc); - assert(uc.theString.equals("not set")); + assertTrue((uc.theString.equals("not set"))); // uc.theString="it is set"; morphium.setInEntity(uc, morphium.getMapper().getCollectionName(UncachedSubClass.class), @@ -486,15 +525,21 @@ public void updateProperty(Morphium morphium) throws Exception { "it is set", false, null); - Thread.sleep(100); - assert(uc.theString.equals("it is set")); - morphium.reread(uc); - assert(uc.theString.equals("it is set")); + assertTrue((uc.theString.equals("it is set"))); + // reread may transiently return null on a replica set - an exception inside the + // condition is a hard failure for waitForConditionToBecomeTrue, so guard it + TestUtils.waitForConditionToBecomeTrue(5000, "THE_STRING not updated", + () -> { + UncachedSubClass r = morphium.reread(uc); + return r != null && "it is set".equals(r.theString); + }); uc.setTheString("another value"); morphium.updateUsingFields(uc, "theString"); - Thread.sleep(100); - morphium.reread(uc); - assert(uc.theString.equals("another value")); + TestUtils.waitForConditionToBecomeTrue(5000, "theString not updated", + () -> { + UncachedSubClass r = morphium.reread(uc); + return r != null && "another value".equals(r.theString); + }); for (UncachedSubClass u : morphium.createQueryFor(UncachedSubClass.class).asList()) { log.info(Utils.toJsonString(u)); diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/WhereTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/WhereTest.java index 9bc890fd7..40b72ee00 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/WhereTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/WhereTest.java @@ -14,6 +14,7 @@ import javax.script.ScriptEngineManager; import java.util.List; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -99,7 +100,7 @@ public void whereTest(Morphium morphium) { assertThat(o.getCounter()).describedAs("Counter should be >5 and <10 but is: %d", o.getCounter()).isLessThan(10).isGreaterThan(5); } - assert(morphium.getStatistics().get("X-Entries for: idCache|de.caluga.test.mongo.suite.data.UncachedObject") == null) : "Cached Uncached Object?!?!?!"; + assertTrue((morphium.getStatistics().get("X-Entries for: idCache|de.caluga.test.mongo.suite.data.UncachedObject") == null), "Cached Uncached Object?!?!?!"); } } } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/WriteBufferCountTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/WriteBufferCountTest.java index c8fedd739..89e762254 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/WriteBufferCountTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/base/WriteBufferCountTest.java @@ -57,7 +57,7 @@ public void onOperationError(AsyncOperationType type, Query q, l }); waitForWriteProcessToBeScheduled(morphium); int c = morphium.getWriteBufferCount(); - assert (c != 0); + assertTrue((c != 0)); long s = System.currentTimeMillis(); while (TestUtils.countUC(morphium) < 10000) { @@ -74,7 +74,7 @@ private int waitForWriteProcessToBeScheduled(Morphium morphium) { c = morphium.getWriteBufferCount(); ++cnt; Thread.yield(); - assert (cnt < 1000000); + assertTrue((cnt < 1000000)); } return c; } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/encrypt/EncryptedObjectMappingTests.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/encrypt/EncryptedObjectMappingTests.java index 6af064935..bd39b941c 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/encrypt/EncryptedObjectMappingTests.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/encrypt/EncryptedObjectMappingTests.java @@ -17,6 +17,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import de.caluga.morphium.Morphium; +import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("encryption") public class EncryptedObjectMappingTests extends MultiDriverTestBase { @@ -43,13 +44,13 @@ public void objectMapperTest(Morphium morphium) throws Exception { ent.sub.name = "name of the document"; Map serialized = om.serialize(ent); - assert (!ent.enc.equals(serialized.get("enc"))); + assertTrue((!ent.enc.equals(serialized.get("enc")))); EncryptedEntity deserialized = om.deserialize(EncryptedEntity.class, serialized); - assert (deserialized.enc.equals(ent.enc)); - assert (ent.intValue.equals(deserialized.intValue)); - assert (ent.floatValue.equals(deserialized.floatValue)); - assert (ent.listOfStrings.equals(deserialized.listOfStrings)); + assertTrue((deserialized.enc.equals(ent.enc))); + assertTrue((ent.intValue.equals(deserialized.intValue))); + assertTrue((ent.floatValue.equals(deserialized.floatValue))); + assertTrue((ent.listOfStrings.equals(deserialized.listOfStrings))); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/encrypt/EncryptionTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/encrypt/EncryptionTest.java index 6d7a65ed0..92b0a979c 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/encrypt/EncryptionTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/encrypt/EncryptionTest.java @@ -11,6 +11,7 @@ import java.util.Arrays; import java.util.Base64; import java.util.Properties; +import static org.junit.jupiter.api.Assertions.assertTrue; @Tag("encryption") public class EncryptionTest { @@ -26,15 +27,15 @@ public void propertyKeyProviderTest() { encProvider.readFromProperties(p, null, null, false); byte[] ek = encProvider.getDecryptionKey("key1"); - assert (Arrays.equals(ek, p.getProperty("key1").getBytes())); + assertTrue((Arrays.equals(ek, p.getProperty("key1").getBytes()))); ek = encProvider.getEncryptionKey("key1"); - assert (Arrays.equals(ek, p.getProperty("key1").getBytes())); + assertTrue((Arrays.equals(ek, p.getProperty("key1").getBytes()))); ek = encProvider.getEncryptionKey("key2"); - assert (Arrays.equals(ek, p.getProperty("key2.enc").getBytes())); + assertTrue((Arrays.equals(ek, p.getProperty("key2.enc").getBytes()))); ek = encProvider.getDecryptionKey("key2"); - assert (Arrays.equals(ek, p.getProperty("key2.dec").getBytes())); + assertTrue((Arrays.equals(ek, p.getProperty("key2.dec").getBytes()))); } @Test @@ -51,15 +52,15 @@ public void propertyKeyProviderEncryptedTest() { encProvider.readFromProperties(p, null, encryptionKey, true); byte[] ek = encProvider.getDecryptionKey("key1"); - assert (Arrays.equals(ek, "12345".getBytes())); + assertTrue((Arrays.equals(ek, "12345".getBytes()))); ek = encProvider.getEncryptionKey("key1"); - assert (Arrays.equals(ek, "12345".getBytes())); + assertTrue((Arrays.equals(ek, "12345".getBytes()))); ek = encProvider.getEncryptionKey("key2"); - assert (Arrays.equals(ek, "12345".getBytes())); + assertTrue((Arrays.equals(ek, "12345".getBytes()))); ek = encProvider.getDecryptionKey("key2"); - assert (Arrays.equals(ek, "123456".getBytes())); + assertTrue((Arrays.equals(ek, "123456".getBytes()))); } @@ -73,7 +74,7 @@ public void aesEncryptionProviderTest() { byte[] encrypted = aes.encrypt(original.getBytes()); byte[] decrypted = aes.decrypt(encrypted); - assert (Arrays.equals(original.getBytes(), decrypted)); + assertTrue((Arrays.equals(original.getBytes(), decrypted))); } @Test @@ -90,7 +91,7 @@ public void rsaEncryptionProviderTest() { byte[] enc = provider.encrypt(originalData.getBytes()); byte[] dec = provider.decrypt(enc); - assert (Arrays.equals(dec, originalData.getBytes())); + assertTrue((Arrays.equals(dec, originalData.getBytes()))); } diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/inmem/ChangeStreamHistoryByteBudgetTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/inmem/ChangeStreamHistoryByteBudgetTest.java new file mode 100644 index 000000000..27fa3c712 --- /dev/null +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/inmem/ChangeStreamHistoryByteBudgetTest.java @@ -0,0 +1,192 @@ +package de.caluga.test.mongo.suite.inmem; + +import de.caluga.morphium.driver.Doc; +import de.caluga.morphium.driver.bson.BsonEncoder; +import de.caluga.morphium.driver.inmem.InMemoryDriver; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.Date; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Tests for the byte budget on the change-stream replay buffer (spec + * docs/superpowers/specs/2026-08-14-replay-buffer-byte-budget.md). + * + *

The buffer is count-capped ({@code setChangeStreamHistoryLimit}) but used to be unbounded + * by bytes: every buffered event retains its full document, so 100k bulk-write events could + * pin several GB of heap (ACC incident 2026-08-14). The byte budget evicts oldest events once + * the estimated buffered bytes exceed it — same window-lost semantics as count overflow. + */ +@Tag("inmemory") +public class ChangeStreamHistoryByteBudgetTest { + + private static final String DB = "bytebudget"; + + private static Map bigDoc(int i, int payloadBytes) { + return Doc.of("_id", "big" + i, "payload", "x".repeat(payloadBytes)); + } + + private static InMemoryDriver freshDriver() throws Exception { + InMemoryDriver drv = new InMemoryDriver(); + drv.connect(); + return drv; + } + + @Test + public void budgetEvictsOldestUntilUnderBudget() throws Exception { + InMemoryDriver drv = freshDriver(); + try { + drv.setChangeStreamHistoryByteBudget(100 * 1024); + for (int i = 0; i < 50; i++) { + drv.store(DB, "coll", List.of(bigDoc(i, 10 * 1024)), null); + } + assertTrue(drv.getChangeStreamHistorySize() > 1, "several events must fit the budget"); + assertTrue(drv.getChangeStreamHistorySize() < 50, "budget must have evicted old events"); + assertTrue(drv.getChangeStreamHistoryBytes() <= 100 * 1024, + "buffered bytes must not exceed the budget (was " + drv.getChangeStreamHistoryBytes() + ")"); + // oldest events are gone -> a resume token from the evicted range is window-lost + assertFalse(drv.canResumeChangeStream(1), + "token in the evicted range must not be resumable"); + // the newest event is always retained -> caught-up consumers resume fine + assertTrue(drv.canResumeChangeStream(drv.getChangeStreamSequence()), + "a caught-up consumer must be resumable"); + } finally { + drv.close(); + } + } + + @Test + public void countLimitStillEnforcedIndependently() throws Exception { + InMemoryDriver drv = freshDriver(); + try { + drv.setChangeStreamHistoryLimit(5); + drv.setChangeStreamHistoryByteBudget(Long.MAX_VALUE); + for (int i = 0; i < 10; i++) { + drv.store(DB, "coll", List.of(Doc.of("_id", "s" + i, "v", i)), null); + } + assertEquals(5, drv.getChangeStreamHistorySize(), + "count limit must evict independent of a generous byte budget"); + } finally { + drv.close(); + } + } + + @Test + public void oversizedEventIsKeptAsOnlyEntry() throws Exception { + InMemoryDriver drv = freshDriver(); + try { + drv.setChangeStreamHistoryByteBudget(1024); + drv.store(DB, "coll", List.of(bigDoc(1, 64 * 1024)), null); + assertEquals(1, drv.getChangeStreamHistorySize(), + "an event bigger than the budget must still be buffered"); + // a second oversized event replaces the first instead of looping forever + drv.store(DB, "coll", List.of(bigDoc(2, 64 * 1024)), null); + assertEquals(1, drv.getChangeStreamHistorySize(), + "the newest oversized event must replace the previous one"); + } finally { + drv.close(); + } + } + + @Test + public void dropPurgesKeepByteCounterConsistent() throws Exception { + InMemoryDriver drv = freshDriver(); + try { + drv.setChangeStreamHistoryByteBudget(Long.MAX_VALUE); + for (int i = 0; i < 5; i++) { + drv.store(DB, "collA", List.of(bigDoc(i, 8 * 1024)), null); + drv.store(DB, "collB", List.of(bigDoc(100 + i, 8 * 1024)), null); + } + long before = drv.getChangeStreamHistoryBytes(); + assertTrue(before > 0); + + drv.drop(DB, "collA", null); + long afterCollDrop = drv.getChangeStreamHistoryBytes(); + assertTrue(afterCollDrop < before, "dropping collA must release its buffered event bytes"); + assertTrue(afterCollDrop > 0, "collB events must still be buffered"); + + // drop(db) purges all buffered events, then appends one small dropDatabase + // notification event - only that may remain + drv.drop(DB, null); + assertTrue(drv.getChangeStreamHistorySize() <= 1, + "at most the dropDatabase notification may remain buffered"); + assertTrue(drv.getChangeStreamHistoryBytes() < 4096, + "all big event bytes must be purged, was " + drv.getChangeStreamHistoryBytes()); + } finally { + drv.close(); + } + } + + @Test + public void shrinkingBudgetTrimsImmediately_zeroDisables() throws Exception { + InMemoryDriver drv = freshDriver(); + try { + for (int i = 0; i < 20; i++) { + drv.store(DB, "coll", List.of(bigDoc(i, 10 * 1024)), null); + } + long unbounded = drv.getChangeStreamHistoryBytes(); + assertTrue(unbounded > 50 * 1024, "default budget 0 must not evict by bytes"); + assertEquals(20, drv.getChangeStreamHistorySize()); + + drv.setChangeStreamHistoryByteBudget(50 * 1024); + assertTrue(drv.getChangeStreamHistoryBytes() <= 50 * 1024, + "shrinking the budget must trim immediately"); + assertTrue(drv.getChangeStreamHistorySize() < 20); + + drv.setChangeStreamHistoryByteBudget(0); // off again + for (int i = 100; i < 120; i++) { + drv.store(DB, "coll", List.of(bigDoc(i, 10 * 1024)), null); + } + assertTrue(drv.getChangeStreamHistoryBytes() > 50 * 1024, + "budget 0 must disable byte eviction again"); + + assertThrows(IllegalArgumentException.class, () -> drv.setChangeStreamHistoryByteBudget(-1)); + } finally { + drv.close(); + } + } + + @Test + public void resumeWindowSurvivesWithinRetainedRange() throws Exception { + InMemoryDriver drv = freshDriver(); + try { + drv.setChangeStreamHistoryByteBudget(100 * 1024); + for (int i = 0; i < 30; i++) { + drv.store(DB, "coll", List.of(bigDoc(i, 10 * 1024)), null); + } + long newest = drv.getChangeStreamSequence(); + // a token just before the newest event lies inside the retained window + assertTrue(drv.canResumeChangeStream(newest - 1), + "token within the retained window must be resumable"); + assertFalse(drv.canResumeChangeStream(1), + "token before the byte-evicted range must force a re-sync"); + } finally { + drv.close(); + } + } + + @Test + public void estimatorTracksBsonSizeWithinFactorTwo() { + Map[] docs = new Map[] { + Doc.of("_id", "a", "s", "hello world", "n", 42, "d", 3.14, "b", true), + Doc.of("_id", "b", "bin", new byte[4096], "date", new Date()), + Doc.of("_id", "c", "nested", Doc.of("x", List.of(1, 2, 3), "y", Doc.of("z", "deep")), + "list", List.of("one", "two", "three")), + bigDoc(1, 32 * 1024), + }; + + for (Map doc : docs) { + long bson = BsonEncoder.encodeDocument(doc).length; + long est = InMemoryDriver.estimateBsonSize(doc); + assertTrue(est >= bson / 2 && est <= bson * 2, + "estimate " + est + " must be within factor 2 of BSON size " + bson + " for " + doc.keySet()); + } + } +} diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/inmem/ChangeStreamInMemTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/inmem/ChangeStreamInMemTest.java index 764134df5..0a4ba8eb0 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/inmem/ChangeStreamInMemTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/inmem/ChangeStreamInMemTest.java @@ -72,9 +72,9 @@ public void changeStreamDatabaseTest() throws Exception { () -> count.get() == 3); //the listener needs to be called to return false ;-) run[0] = false; // stop the monitor AFTER the 3rd event is confirmed morphium.store(new UncachedObject("test", 123)); //to have the monitor stop - assert(3 == count.get()) : "Count wrong " + count.get() + "!=3"; + assertTrue((3 == count.get()), () -> String.valueOf("Count wrong " + count.get() + "!=3")); morphium.store(new UncachedObject("test again", 124)); - assert(3 == count.get()) : "Count wrong " + count.get() + "!=3"; //monitor should have stopped by now + assertTrue((3 == count.get()), () -> String.valueOf("Count wrong " + count.get() + "!=3")); //monitor should have stopped by now } finally { dbMonitor.terminate(); } @@ -171,7 +171,7 @@ public void changeStreamInsertTest() throws Exception { } return System.currentTimeMillis() - start < 8500; }); - assert(count[0] >= written[0] - 1 && count[0] <= written[0]); + assertTrue((count[0] >= written[0] - 1 && count[0] <= written[0])); log.info("Stopped!"); run[0] = false; writerThread.interrupt(); diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/inmem/InMemAggregationTests.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/inmem/InMemAggregationTests.java index aa169f1b9..39de304d7 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/inmem/InMemAggregationTests.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/inmem/InMemAggregationTests.java @@ -89,12 +89,12 @@ public void inMemAggregationSumTest() throws Exception { log.info(Utils.toJsonString(o)); } - assert (lst.size() == 1); - assert (((Number) lst.get(0).get("summe")).doubleValue() == 1683); - assert (((Number) lst.get(0).get("tst")).doubleValue() == 1683); - assert (((Number) lst.get(0).get("cnt")).doubleValue() == 34); - assert (((Number) lst.get(0).get("avg")).doubleValue() == 49.5); - assert (lst.get(0).get("_id").equals("mod0")); + assertTrue((lst.size() == 1)); + assertTrue((((Number) lst.get(0).get("summe")).doubleValue() == 1683)); + assertTrue((((Number) lst.get(0).get("tst")).doubleValue() == 1683)); + assertTrue((((Number) lst.get(0).get("cnt")).doubleValue() == 34)); + assertTrue((((Number) lst.get(0).get("avg")).doubleValue() == 49.5)); + assertTrue((lst.get(0).get("_id").equals("mod0"))); } @@ -112,13 +112,13 @@ public void inMemAggregationFirstLastTest() throws Exception { for (Map o : lst) { log.info(Utils.toJsonString(o)); } - assert (lst.size() == 3); - assert (((Number) lst.get(0).get("cnt")).doubleValue() == 0); - assert (((Number) lst.get(1).get("cnt")).doubleValue() == 1); - assert (((Number) lst.get(2).get("cnt")).doubleValue() == 2); - assert (((Number) lst.get(0).get("lst")).doubleValue() == 99); - assert (((Number) lst.get(1).get("lst")).doubleValue() == 97); - assert (((Number) lst.get(2).get("lst")).doubleValue() == 98); + assertTrue((lst.size() == 3)); + assertTrue((((Number) lst.get(0).get("cnt")).doubleValue() == 0)); + assertTrue((((Number) lst.get(1).get("cnt")).doubleValue() == 1)); + assertTrue((((Number) lst.get(2).get("cnt")).doubleValue() == 2)); + assertTrue((((Number) lst.get(0).get("lst")).doubleValue() == 99)); + assertTrue((((Number) lst.get(1).get("lst")).doubleValue() == 97)); + assertTrue((((Number) lst.get(2).get("lst")).doubleValue() == 98)); } @Test @@ -136,14 +136,14 @@ public void inMemAggregationSortTest() throws Exception { for (Map o : lst) { log.info(Utils.toJsonString(o)); if (lastValue.equals(o.get("str_value"))) { - assert (((Number) o.get("counter")).intValue() < lastCounter) : "LastCounter: " + lastCounter + " got: " + o.get("counter"); + assertTrue((((Number) o.get("counter")).intValue() < lastCounter), String.valueOf("LastCounter: " + lastCounter + " got: " + o.get("counter"))); lastCounter = ((Number) o.get("counter")).intValue(); } else { lastCounter = 100; lastValue = (String) o.get("str_value"); } - assert (lastValue.compareTo((String) o.get("str_value")) <= 0) : "LastValue: " + lastValue + " current: " + o.get("str_value"); + assertTrue((lastValue.compareTo((String) o.get("str_value")) <= 0), String.valueOf("LastValue: " + lastValue + " current: " + o.get("str_value"))); } } @@ -158,8 +158,8 @@ public void inMemAggregationCountTest() throws Exception { agg.count("myCount"); List> lst = agg.aggregateMap(); log.info(Utils.toJsonString(lst.get(0))); - assert (lst.size() == 1); - assert (lst.get(0).get("myCount").equals(100)); + assertTrue((lst.size() == 1)); + assertTrue((lst.get(0).get("myCount").equals(100))); } @Test @@ -168,10 +168,10 @@ public void inMemAggregationCountEmptyInputTest() throws Exception { Aggregator agg = morphium.createAggregator(UncachedObject.class, Map.class); agg.count("myCount"); List> lst = agg.aggregateMap(); - assert (lst.isEmpty()) : "$count on empty input must yield no document, got: " + lst; + assertTrue((lst.isEmpty()), () -> String.valueOf("$count on empty input must yield no document, got: " + lst)); Aggregator agg2 = morphium.createAggregator(UncachedObject.class, Map.class); - assert (agg2.getCount() == 0) : "getCount() on empty collection must be 0"; + assertTrue((agg2.getCount() == 0), "getCount() on empty collection must be 0"); } @Test @@ -184,8 +184,8 @@ public void inMemAggregationPushTest() throws Exception { agg.group("all").push("mods", "$value"); List> lst = agg.aggregateMap(); log.info(Utils.toJsonString(lst.get(0))); - assert (lst.size() == 1); - assert (((List) lst.get(0).get("mods")).size() == 100); + assertTrue((lst.size() == 1)); + assertTrue((((List) lst.get(0).get("mods")).size() == 100)); } @Test @@ -440,7 +440,7 @@ public void inMemAggregationSampleTest() throws Exception { agg.sample(10); agg.sort("counter"); List> lst = agg.aggregateMap(); - assert (lst.size() == 10); + assertTrue((lst.size() == 10)); //hard to check randomness.... } @@ -455,7 +455,7 @@ public void inMemAggregationAddToSetTest() throws Exception { agg.group("all").addToSet("mods", "$str_value"); List> lst = agg.aggregateMap(); log.info(Utils.toJsonString(lst.get(0))); - assert (lst.size() == 1); + assertTrue((lst.size() == 1)); assertEquals (3, ((List) lst.get(0).get("mods")).size()); } @@ -469,8 +469,8 @@ public void inMemAggregationCountObjectTest() throws Exception { agg.count("my_count"); List lst = agg.aggregate(); log.info(Utils.toJsonString(lst.get(0))); - assert (lst.size() == 1); - assert (lst.get(0).getMyCount() == 100); + assertTrue((lst.size() == 1)); + assertTrue((lst.get(0).getMyCount() == 100)); } @@ -533,10 +533,10 @@ public void unwindTest() throws Exception { List> result = agg.aggregateMap(); assertNotNull(result); ; - assert (result.size() == 1000); + assertTrue((result.size() == 1000)); assertNotNull(result.get(0).get("long_list")); ; - assert (!(result.get(1).get("long_list") instanceof List)); + assertTrue((!(result.get(1).get("long_list") instanceof List))); } @Embedded diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/inmem/InMemDumpTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/inmem/InMemDumpTest.java index 40508da92..4251a6ae1 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/inmem/InMemDumpTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/inmem/InMemDumpTest.java @@ -22,6 +22,7 @@ import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; @SuppressWarnings("unchecked") @Tag("inmemory") @@ -63,8 +64,8 @@ public ObjectId unmarshall(Object d) { assertNotNull(ex);; ((InMemoryDriver) morphium.getDriver()).setDatabase(morphium.getDriver().listDatabases().get(0), ex.data); List result = morphium.createQueryFor(UncachedObject.class).asList(); - assert(result.size() == 10); - assert(result.get(1).getCounter() == 1); + assertTrue((result.size() == 10)); + assertTrue((result.get(1).getCounter() == 1)); } @Test diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/inmem/InMemTransactionTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/inmem/InMemTransactionTest.java index 41678d24c..9c57cef8e 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/inmem/InMemTransactionTest.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/inmem/InMemTransactionTest.java @@ -7,6 +7,7 @@ import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * User: Stephan Bösebeck @@ -25,7 +26,7 @@ public void transactionTest() throws Exception { UncachedObject u = new UncachedObject("test", 101); morphium.store(u); long l = TestUtils.countUC(morphium); - assert (l == 11) : "Count wrong: " + l; + assertTrue((l == 11), () -> String.valueOf("Count wrong: " + l)); morphium.abortTransaction(); TestUtils.waitForConditionToBecomeTrue(3000, "Transaction abort not reflected in count", () -> TestUtils.countUC(morphium) == 10); diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/jms/BasicJMSTests.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/jms/BasicJMSTests.java index 3039f9453..baeeed1f6 100644 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/jms/BasicJMSTests.java +++ b/morphium-core/src/test/java/de/caluga/test/mongo/suite/jms/BasicJMSTests.java @@ -168,7 +168,7 @@ public void consumerProducerQueueTest(Morphium morphium) throws Exception { Message msg2 = consumer2.receive(1000); assertTrue(msg != null || msg2 != null); ; - assert (msg != msg2); + assertTrue((msg != msg2)); m.terminate(); m2.terminate(); diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/ncmessaging/AdvancedMessagingNCTests.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/ncmessaging/AdvancedMessagingNCTests.java deleted file mode 100644 index 29831e67e..000000000 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/ncmessaging/AdvancedMessagingNCTests.java +++ /dev/null @@ -1,373 +0,0 @@ -package de.caluga.test.mongo.suite.ncmessaging; -import de.caluga.test.mongo.suite.base.MultiDriverTestBase; - -import de.caluga.morphium.Morphium; -import de.caluga.morphium.MorphiumConfig; -import de.caluga.morphium.driver.MorphiumId; -import de.caluga.morphium.messaging.MessageListener; -import de.caluga.morphium.messaging.MorphiumMessaging; -import de.caluga.morphium.messaging.Msg; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -import static org.junit.jupiter.api.Assertions.assertNotNull; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; - -@Disabled -@Tag("messaging") -public class AdvancedMessagingNCTests extends MultiDriverTestBase { - private final Map counts = new ConcurrentHashMap<>(); - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void testExclusiveXTimes(Morphium morphium) throws Exception { - // morphium.watchAsync("msg", true,new ChangeStreamListener(){ - // - // @Override - // public boolean incomingData(ChangeStreamEvent evt) { - // - // if (evt.getOperationType().equals("insert")){ - // - // storage.put(evt.getDocumentKey(),new ArrayList<>()); - // storage.get(evt.getDocumentKey()).add(evt.getFullDocument()); - // - // } else if (evt.getOperationType().equals("update")){ - // if (evt.getUpdatedFields().containsKey("locked_by")){ - // storage.get(evt.getDocumentKey()).add(evt.getFullDocument()); - // - // } - // } else if (evt.getOperationType().equals("delete")){ - // //storage.remove(evt.getDocumentKey()); - // } - // return true; - // } - // }); - for (int i = 0; i < 2; i++) - runExclusiveMessagesTest(morphium, (int)(Math.random() * 1500), (int)(55 * Math.random()) + 2); - } - - private void runExclusiveMessagesTest(Morphium morphium, int amount, int receivers) throws Exception { - morphium.dropCollection(Msg.class, "msg", null); - Thread.sleep(1000); - List morphiums = new ArrayList<>(); - List messagings = new ArrayList<>(); - MorphiumMessaging sender = null; - sender = morphium.createMessaging(); - sender.setPause(50).setMultithreadded(true).setWindowSize(1).setUseChangeStream(false); - sender.setSenderId("amsender"); - - try { - log.info("Running Exclusive message test - sending " + amount + " exclusive messages, received by " + receivers); - morphium.dropCollection(Msg.class, "msg", null); - log.info("Collection dropped"); - Thread.sleep(100); - counts.clear(); - MessageListener msgMessageListener = (msg, m) -> { - //log.info(msg.getSenderId() + ": Received " + m.getMsgId() + " created " + (System.currentTimeMillis() - m.getTimestamp()) + "ms ago"); - counts.putIfAbsent(m.getMsgId(), 0); - counts.put(m.getMsgId(), counts.get(m.getMsgId()) + 1); - - if (counts.get(m.getMsgId()) > 1) { - log.error("Msg: " + m.getMsgId() + " processed: " + counts.get(m.getMsgId())); - - for (String id : m.getProcessedBy()) { - log.error("... processed by: " + id); - } - } - - try { - Thread.sleep(250); - } catch (InterruptedException e) { - } - - return null; - }; - - for (int i = 0; i < receivers; i++) { - log.info("Creating morphiums..." + i); - Morphium m = new Morphium(MorphiumConfig.fromProperties(morphium.getConfig().asProperties())); - m.getConfig().cacheSettings().setHousekeepingTimeout(100); - morphiums.add(m); - MorphiumMessaging msg = m.createMessaging(); - msg.setPause(50).setMultithreadded(true).setWindowSize((int)(1500 * Math.random())).setUseChangeStream(false); - msg.setSenderId("msg" + i); - msg.setUseChangeStream(false).start(); - messagings.add(msg); - msg.addListenerForTopic("test", msgMessageListener); - } - - for (int i = 0; i < amount; i++) { - if (i % 100 == 0) { - log.info("Sending message " + i + "/" + amount); - } - - Msg m = new Msg("test", "test msg" + i, "value" + i); - m.setMsgId(new MorphiumId()); - m.setExclusive(true); - m.setTtl(600000); - sender.sendMessage(m); - } - - int lastCount = counts.size(); - - while (counts.size() < amount) { - log.info("-----> Messages processed so far: " + counts.size() + "/" + amount + " with " + receivers + " receivers"); - - for (MorphiumId id : counts.keySet()) { - assert(counts.get(id) <= 1) : "Count for id " + id.toString() + " is " + counts.get(id); - } - - Thread.sleep(1000); - assert(counts.size() != lastCount); - log.info("----> current speed: " + (counts.size() - lastCount) + "/sec"); - lastCount = counts.size(); - } - - log.info("-----> Messages processed so far: " + counts.size() + "/" + amount + " with " + receivers + " receivers"); - } finally { - List threads = new ArrayList<>(); - threads.add(new Thread() { - private MorphiumMessaging msg; - public Thread setMessaging(MorphiumMessaging m) { - this.msg = m; - return this; - } - public void run() { - msg.terminate(); - } - } .setMessaging(sender)); - threads.get(0).start(); - sender.terminate(); - - for (MorphiumMessaging m : messagings) { - Thread t = new Thread() { - private MorphiumMessaging msg; - public Thread setMessaging(MorphiumMessaging m) { - this.msg = m; - return this; - } - public void run() { - log.info("Terminating " + m.getSenderId()); - msg.terminate(); - } - } .setMessaging(m); - threads.add(t); - t.start(); - } - - for (Thread t : threads) { - t.join(); - } - - threads.clear(); - int num = 0; - - for (Morphium m : morphiums) { - num++; - Thread t = new Thread() { - private Morphium m; - private int n; - public Thread setMorphium(Morphium m, int num) { - this.m = m; - this.n = num; - return this; - } - public void run() { - log.info("Terminating Morphium " + n + "/" + morphiums.size()); - m.close(); - } - } .setMorphium(m, num); - threads.add(t); - t.start(); - // log.info("Closing morphium..." + num + "/" + morphiums.size()); - // m.close(); - } - - for (Thread t : threads) { - t.join(); - } - - threads.clear(); - log.info("Run finished!"); - } - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void messageAnswerTest(Morphium morphium) throws Exception { - morphium.dropCollection(Msg.class, "msg", null); - Thread.sleep(100); - counts.clear(); - MorphiumMessaging m1 = morphium.createMessaging(); - m1.setPause(100).setMultithreadded(true).setWindowSize(1).setUseChangeStream(false); - m1.setUseChangeStream(false).start(); - Morphium morphium2 = new Morphium(MorphiumConfig.fromProperties(morphium.getConfig().asProperties())); - MorphiumMessaging m2 = morphium2.createMessaging(); - m2.setPause(100).setMultithreadded(true).setWindowSize(1).setUseChangeStream(false); - // m2.setUseChangeStream(false); - m2.setUseChangeStream(false).start(); - Morphium morphium3 = new Morphium(MorphiumConfig.fromProperties(morphium.getConfig().asProperties())); - MorphiumMessaging m3 = morphium3.createMessaging(); - m3.setPause(100).setMultithreadded(true).setWindowSize(1).setUseChangeStream(false); - // m3.setUseChangeStream(false); - m3.setUseChangeStream(false).start(); - Morphium morphium4 = new Morphium(MorphiumConfig.fromProperties(morphium.getConfig().asProperties())); - MorphiumMessaging m4 = morphium4.createMessaging(); - m4.setPause(100).setMultithreadded(true).setWindowSize(1).setUseChangeStream(false); - // m4.setUseChangeStream(false); - m4.setUseChangeStream(false).start(); - - try { - MessageListener msgMessageListener = (msg, m) -> { - log.info("Received " + m.getMsgId() + " created " + (System.currentTimeMillis() - m.getTimestamp()) + "ms ago"); - Msg answer = m.createAnswerMsg(); - answer.setTopic("test_answer"); - return answer; - }; - m2.addListenerForTopic("test", msgMessageListener); - m3.addListenerForTopic("test", msgMessageListener); - m4.addListenerForTopic("test", msgMessageListener); - - for (int i = 0; i < 10; i++) { - Msg query = new Msg("test", "test querey", "query"); - query.setExclusive(true); - List ans = m1.sendAndAwaitAnswers(query, 3, 1250); - assert(ans.size() == 1) : "Recieved more than one answer to query " + query.getMsgId(); - } - - for (int i = 0; i < 10; i++) { - Msg query = new Msg("test", "test querey", "query"); - query.setExclusive(false); - List ans = m1.sendAndAwaitAnswers(query, 3, 1250); - assert(ans.size() == 3) : "Recieved not enough answers to " + query.getMsgId(); - } - } finally { - m1.terminate(); - m2.terminate(); - m3.terminate(); - m4.terminate(); - } - } - // - // - // @Test - // public void testMorphiums() throws Exception { - // - // final Listmorphiums=new ArrayList<>(); - // for (int i=0;i<150;i++) { - // Morphium m = new Morphium(MorphiumConfig.fromProperties(morphium.getConfig().asProperties())); - // m.getConfig().getCache().setHouskeepingIntervalPause(100); - // morphiums.add(m); - // } - // - // - // - // final Msg msg=new Msg("name","msg","value"); - // msg.setSender("test"); - // msg.setMsgId(new MorphiumId()); - // - // final AtomicLong cnt=new AtomicLong(); - // morphium.store(msg); - // - // Thread.sleep(200); - // - // for (int i =0;i<100;i++) { - // cnt.set(0); - // msg.setLocked(System.currentTimeMillis()); - // - // for (final Morphium m:morphiums) { - // new Thread() { - // public void run() { - // while (m.createQueryFor(Msg.class, "msg").f("_id").eq(msg.getMsgId()).get().getLocked() != msg.getLocked()) { - // yield(); - // } - // cnt.incrementAndGet(); - // } - // }.start(); - // } - // - // long start = System.currentTimeMillis(); - // morphium.set(msg, "locked", msg.getLocked()); - // long end=System.currentTimeMillis(); - // while(cnt.get()<150){ - // Thread.yield(); - // } - // log.info("Turnaround update : " + (System.currentTimeMillis() - start)); - // //log.info("Turnaround update (local): " + (end - start)); - // } - // - // - // } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void answerWithDifferentNameTest(Morphium morphium) throws Exception { - counts.clear(); - MorphiumMessaging producer = morphium.createMessaging(); - producer.setPause(100).setMultithreadded(true).setWindowSize(1); - producer.setUseChangeStream(false).start(); - MorphiumMessaging consumer = morphium.createMessaging(); - consumer.setPause(100).setMultithreadded(true).setWindowSize(1); - consumer.setUseChangeStream(false).start(); - Msg answer; - - try { - consumer.addListenerForTopic("testDiff", (msg, m) -> { - log.info("incoming message, replying with answer"); - Msg answer1 = m.createAnswerMsg(); - answer1.setTopic("answer"); - return answer1; - }); - answer = producer.sendAndAwaitFirstAnswer(new Msg("testDiff", "query", "value"), 1000); - assertNotNull(answer); - ; - assert(answer.getTopic().equals("answer")) : "Name is wrong: " + answer.getTopic(); - } finally { - producer.terminate(); - consumer.terminate(); - } - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void ownAnsweringHandler(Morphium morphium) throws Exception { - MorphiumMessaging producer = morphium.createMessaging(); - producer.setPause(100).setMultithreadded(true).setWindowSize(1); - producer.setUseChangeStream(false).start(); - MorphiumMessaging consumer = morphium.createMessaging(); - consumer.setPause(100).setMultithreadded(true).setWindowSize(1); - consumer.setUseChangeStream(false).start(); - - try { - consumer.addListenerForTopic("testAnswering", (msg, m) -> { - log.info("incoming message, replying with answer"); - Msg answer = m.createAnswerMsg(); - answer.setTopic("answerForTestAnswering"); - return answer; - }); - MorphiumId msgId = new MorphiumId(); - producer.addListenerForTopic("answerForTestAnswering", (msg, m) -> { - log.info("Incoming answer! " + m.getInAnswerTo() + " ---> " + msgId); - assert(msgId.equals(m.getInAnswerTo())); - counts.put(msgId, 1); - return null; - }); - Msg msg = new Msg("testAnswering", "query", "value"); - msg.setMsgId(msgId); - producer.sendMessage(msg); - Thread.sleep(1000); - assert(counts.get(msgId).equals(1)); - } finally { - producer.terminate(); - consumer.terminate(); - } - } -} diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/ncmessaging/AnsweringNCTests.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/ncmessaging/AnsweringNCTests.java deleted file mode 100644 index d3a2727bf..000000000 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/ncmessaging/AnsweringNCTests.java +++ /dev/null @@ -1,498 +0,0 @@ -package de.caluga.test.mongo.suite.ncmessaging; -import de.caluga.test.mongo.suite.base.MultiDriverTestBase; - -import de.caluga.morphium.Morphium; -import de.caluga.morphium.MorphiumConfig; -import de.caluga.morphium.driver.MorphiumId; -import de.caluga.morphium.messaging.MessageListener; -import de.caluga.morphium.messaging.MorphiumMessaging; -import de.caluga.morphium.messaging.Msg; -import de.caluga.test.OutputHelper; -import de.caluga.test.mongo.suite.base.TestUtils; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; - -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.jupiter.api.Assertions.*; - -@Tag("messaging") -public class AnsweringNCTests extends MultiDriverTestBase { - private final List list = new ArrayList<>(); - private final AtomicInteger queueCount = new AtomicInteger(1000); - public boolean gotMessage = false; - public boolean gotMessage1 = false; - public boolean gotMessage2 = false; - public boolean gotMessage3 = false; - public boolean gotMessage4 = false; - public boolean error = false; - public MorphiumId lastMsgId; - public AtomicInteger procCounter = new AtomicInteger(0); - - @ParameterizedTest - @MethodSource("de.caluga.test.mongo.suite.base.MultiDriverTestBase#getMorphiumInstancesNoSingle") - public void answeringTest(Morphium morphium) throws Exception { - String tstName = new Object() {} .getClass().getEnclosingMethod().getName(); - log.info("Running test " + tstName + " with " + morphium.getDriver().getName()); - - gotMessage1 = false; - gotMessage2 = false; - gotMessage3 = false; - error = false; - - try (morphium) { - for (String msgImpl : MultiDriverTestBase.messagingsToTest) { - OutputHelper.figletOutput(log, msgImpl); - MorphiumConfig cfg = morphium.getConfig().createCopy(); - cfg.messagingSettings().setMessagingImplementation(msgImpl); - cfg.encryptionSettings().setCredentialsEncrypted(morphium.getConfig().encryptionSettings().getCredentialsEncrypted()); - cfg.encryptionSettings().setCredentialsDecryptionKey(morphium.getConfig().encryptionSettings().getCredentialsDecryptionKey()); - cfg.encryptionSettings().setCredentialsEncryptionKey(morphium.getConfig().encryptionSettings().getCredentialsEncryptionKey()); - - try (Morphium morph = new Morphium(cfg)) { - morph.dropCollection(Msg.class); - // Clear all msg-related collections to ensure clean state between messaging implementations - morph.listCollections().stream() - .filter(c -> c.startsWith("msg") || c.startsWith("dm_")) - .forEach(c -> morph.dropCollection(Msg.class, c, null)); - final MorphiumMessaging m1; - final MorphiumMessaging m2; - final MorphiumMessaging onlyAnswers; - m1 = morph.createMessaging(); - m2 = morph.createMessaging(); - onlyAnswers = morph.createMessaging(); - try { - m1.setUseChangeStream(false).start(); - m2.setUseChangeStream(false).start(); - onlyAnswers.setUseChangeStream(false).start(); - assertTrue(m1.waitForReady(30, TimeUnit.SECONDS), "m1 not ready"); - assertTrue(m2.waitForReady(30, TimeUnit.SECONDS), "m2 not ready"); - assertTrue(onlyAnswers.waitForReady(30, TimeUnit.SECONDS), "onlyAnswers not ready"); - Thread.sleep(100); - - log.info("m1 ID: " + m1.getSenderId()); - log.info("m2 ID: " + m2.getSenderId()); - log.info("onlyAnswers ID: " + onlyAnswers.getSenderId()); - - m1.addListenerForTopic("test", (msg, m) -> { - gotMessage1 = true; - if (m.getTo() != null && !m.getTo().contains(m1.getSenderId())) { - log.error("wrongly received message?"); - error = true; - } - if (m.getInAnswerTo() != null) { - log.error("M1 got an answer, but did not ask?"); - error = true; - } - log.info("M1 got message " + m.toString()); - Msg answer = m.createAnswerMsg(); - answer.setValue("This is the answer from m1"); - answer.addValue("something", new Date()); - answer.addAdditional("String message from m1"); - return answer; - }); - - m2.addListenerForTopic("test", (msg, m) -> { - gotMessage2 = true; - if (m.getTo() != null && !m.getTo().contains(m2.getSenderId())) { - log.error("wrongly received message?"); - error = true; - } - log.info("M2 got message " + m.toString()); - assert (m.getInAnswerTo() == null) : "M2 got an answer, but did not ask?"; - Msg answer = m.createAnswerMsg(); - answer.setValue("This is the answer from m2"); - answer.addValue("when", System.currentTimeMillis()); - answer.addAdditional("Additional Value von m2"); - return answer; - }); - - onlyAnswers.addListenerForTopic("test", (msg, m) -> { - gotMessage3 = true; - if (m.getTo() != null && !m.getTo().contains(onlyAnswers.getSenderId())) { - log.error("wrongly received message?"); - error = true; - } - - assertNotNull(m.getInAnswerTo(), "was not an answer? " + m.toString()); - - log.info("M3 got answer " + m.toString()); - assertNotNull(lastMsgId, "Last message == null?"); - assert (m.getInAnswerTo().equals(lastMsgId)) : "Wrong answer????" + lastMsgId.toString() + " != " + m.getInAnswerTo().toString(); - // assert (m.getSender().equals(m1.getSenderId())) : "Sender is not M1?!?!? m1_id: " + m1.getSenderId() + " - message sender: " + m.getSender(); - return null; - }); - // Small delay for topic listeners to be fully registered - Thread.sleep(1000); - - // Allow listeners to be registered before sending messages - Thread.sleep(1000); - - Msg question = new Msg("test", "This is the message text", "A question param"); - question.setMsgId(new MorphiumId()); - lastMsgId = question.getMsgId(); - onlyAnswers.sendMessage(question); - log.info("Send Message with id: " + question.getMsgId()); - Thread.sleep(3000); - long cnt = morph.createQueryFor(Msg.class, onlyAnswers.getDMCollectionName(onlyAnswers.getSenderId())).f(Msg.Fields.inAnswerTo).eq(question.getMsgId()).countAll(); - log.info("Answers in mongo: " + cnt); - assert (cnt == 2); - assert (gotMessage3) : "no answer got back?"; - assert (gotMessage1) : "Question not received by m1"; - assert (gotMessage2) : "Question not received by m2"; - assert (!error); - gotMessage1 = false; - gotMessage2 = false; - gotMessage3 = false; - Thread.sleep(2000); - assert (!error); - - assert (!gotMessage3 && !gotMessage1 && !gotMessage2) : "Message processing repeat?"; - - question = new Msg("test", "This is the message text", "A question param", 30000, true); - question.setMsgId(new MorphiumId()); - lastMsgId = question.getMsgId(); - gotMessage1 = false; - gotMessage2 = false; - gotMessage3 = false; - onlyAnswers.sendMessage(question); - log.info("Send exclusive Message with id: " + question.getMsgId()); - final MorphiumId questionId = question.getMsgId(); - // Wait for either m1 or m2 to process (exclusive means only one) - TestUtils.waitForConditionToBecomeTrue(15000, "Exclusive message not processed by any listener", () -> gotMessage1 || gotMessage2); - log.info("Exclusive message processed by m1={} m2={}", gotMessage1, gotMessage2); - // Now wait for the answer to arrive in the DM collection - String dmCollection = onlyAnswers.getDMCollectionName(onlyAnswers.getSenderId()); - log.info("Checking for answer in DM collection: {}", dmCollection); - TestUtils.waitForConditionToBecomeTrue(15000, "Answer not received in DM collection", () -> - morph.createQueryFor(Msg.class, dmCollection).f(Msg.Fields.inAnswerTo).eq(questionId).countAll() == 1 - ); - log.info("Answer received for exclusive message"); - - } finally { - m1.terminate(); - m2.terminate(); - onlyAnswers.terminate(); - Thread.sleep(100); - } - } - } - } - - } - - - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void answerExclusiveMessagesTest(Morphium morphium) throws Exception { - MorphiumMessaging m1 = morphium.createMessaging().setPause(10).setMultithreadded(false).setWindowSize(10); - m1.setSenderId("m1"); - MorphiumMessaging m2 = morphium.createMessaging().setPause(10).setMultithreadded(false).setWindowSize(10); - m2.setSenderId("m2"); - MorphiumMessaging m3 = morphium.createMessaging().setPause(10).setMultithreadded(false).setWindowSize(10); - m3.setSenderId("m3"); - m3.addListenerForTopic("test", (msg, m) -> { - log.info("Incoming message"); - return m.createAnswerMsg(); - }); - Thread.sleep(1000); // Allow topic listener to register - - m1.setUseChangeStream(false).start(); - m2.setUseChangeStream(false).start(); - m3.setUseChangeStream(false).start(); - assertTrue(m1.waitForReady(30, TimeUnit.SECONDS), "m1 not ready"); - assertTrue(m2.waitForReady(30, TimeUnit.SECONDS), "m2 not ready"); - assertTrue(m3.waitForReady(30, TimeUnit.SECONDS), "m3 not ready"); - Thread.sleep(1000); // Allow listener registration - - Msg m = new Msg("test", "important", "value"); - m.setExclusive(true); - Msg answer = m1.sendAndAwaitFirstAnswer(m, 60000); - Thread.sleep(500); - assertNotNull(answer); - ; - assert (answer.getProcessedBy().size() == 1) : "Size wrong: " + answer.getProcessedBy(); - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void answers3NodesTest(Morphium morphium) throws Exception { - MorphiumMessaging m1 = morphium.createMessaging().setPause(10).setMultithreadded(false).setWindowSize(10); - m1.setSenderId("m1"); - MorphiumMessaging m2 = morphium.createMessaging().setPause(10).setMultithreadded(false).setWindowSize(10); - m2.setSenderId("m2"); - MorphiumMessaging mSrv = morphium.createMessaging().setPause(10).setMultithreadded(false).setWindowSize(10); - mSrv.setSenderId("Srv"); - - mSrv.addListenerForTopic("query", (msg, m) -> { - log.info("Incoming message - sending result"); - Msg answer = m.createAnswerMsg(); - answer.setValue("Result"); - return answer; - }); - - m1.setUseChangeStream(false).start(); - m2.setUseChangeStream(false).start(); - mSrv.setUseChangeStream(false).start(); - assertTrue(m1.waitForReady(30, TimeUnit.SECONDS), "m1 not ready"); - assertTrue(m2.waitForReady(30, TimeUnit.SECONDS), "m2 not ready"); - assertTrue(mSrv.waitForReady(30, TimeUnit.SECONDS), "mSrv not ready"); - Thread.sleep(1000); - - - for (int i = 0; i < 10; i++) { - Msg m = new Msg("query", "a message", "a query"); - m.setExclusive(true); - log.info("Sending m1..."); - Msg answer1 = m1.sendAndAwaitFirstAnswer(m, 1000); - assertNotNull(answer1); - ; - m = new Msg("query", "a message", "a query"); - log.info("... got it. Sending m2"); - Msg answer2 = m2.sendAndAwaitFirstAnswer(m, 1000); - assertNotNull(answer2); - ; - log.info("... got it."); - } - - m1.terminate(); - m2.terminate(); - mSrv.terminate(); - - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - @Disabled - public void getAnswersTest(Morphium morphium) throws Exception { - MorphiumMessaging m1 = morphium.createMessaging().setPause(10).setMultithreadded(false).setWindowSize(10); - MorphiumMessaging m2 = morphium.createMessaging().setPause(10).setMultithreadded(false).setWindowSize(10); - MorphiumMessaging mTst = morphium.createMessaging().setPause(10).setMultithreadded(false).setWindowSize(10); - - mTst.addListenerForTopic("somethign else", (msg, m) -> { - log.info("incoming message??"); - return null; - }); - - m2.addListenerForTopic("question", (msg, m) -> { - Msg answer = m.createAnswerMsg(); - msg.sendMessage(answer); - try { - Thread.sleep(1000); - } catch (InterruptedException e) { - } - answer = m.createAnswerMsg(); - return answer; - }); - - m1.setUseChangeStream(false).start(); - m2.setUseChangeStream(false).start(); - mTst.setUseChangeStream(false).start(); - assertTrue(m1.waitForReady(30, TimeUnit.SECONDS), "m1 not ready"); - assertTrue(m2.waitForReady(30, TimeUnit.SECONDS), "m2 not ready"); - assertTrue(mTst.waitForReady(30, TimeUnit.SECONDS), "mTst not ready"); - Thread.sleep(1000); // Allow listener registration - - Msg m3 = new Msg("not asdf", "will it stuck", "uahh", 10000); - m3.setPriority(1); - m1.sendMessage(m3); - Thread.sleep(5000); - - Msg question = new Msg("question", "question", "a value"); - question.setPriority(5); - List answers = m1.sendAndAwaitAnswers(question, 2, 10000); - assert (answers != null && !answers.isEmpty()); - assert (answers.size() == 2) : "Got wrong number of answers: " + answers.size(); - for (Msg m : answers) { - assertNotNull(m.getInAnswerTo()); - ; - assert (m.getInAnswerTo().equals(question.getMsgId())); - } - m1.terminate(); - m2.terminate(); - mTst.terminate(); - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void waitForAnswerTest(Morphium morphium) throws Exception { - - MorphiumMessaging m1 = morphium.createMessaging().setPause(10).setMultithreadded(false).setWindowSize(10); - MorphiumMessaging m2 = morphium.createMessaging().setPause(10).setMultithreadded(false).setWindowSize(10); - m1.setSenderId("m1"); - m2.setSenderId("m2"); - - m2.addListenerForTopic("question", (msg, m) -> { - Msg answer = m.createAnswerMsg(); - return answer; - }); - Thread.sleep(1000); // Allow topic listener to register - - m1.setUseChangeStream(false).start(); - m2.setUseChangeStream(false).start(); - assertTrue(m1.waitForReady(30, TimeUnit.SECONDS), "m1 not ready"); - assertTrue(m2.waitForReady(30, TimeUnit.SECONDS), "m2 not ready"); - Thread.sleep(1000); // Allow messaging to fully start - - for (int i = 0; i < 100; i++) { - log.info("Sending msg " + i); - Msg question = new Msg("question", "question" + i, "a value " + i); - question.setPriority(5); - long start = System.currentTimeMillis(); - Msg answer = m1.sendAndAwaitFirstAnswer(question, 15000); - long dur = System.currentTimeMillis() - start; - assertTrue(answer != null && answer.getInAnswerTo() != null); - assert (answer.getInAnswerTo().equals(question.getMsgId())); - log.info("... ok - took " + dur + " ms"); - } - m1.terminate(); - m2.terminate(); - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - @Disabled - public void answerWithoutListener(Morphium morphium) throws Exception { - MorphiumMessaging m1 = morphium.createMessaging().setPause(10).setMultithreadded(false).setWindowSize(10); - MorphiumMessaging m2 = morphium.createMessaging().setPause(10).setMultithreadded(false).setWindowSize(10); - - m1.setUseChangeStream(false).start(); - m2.setUseChangeStream(false).start(); - - m2.addListenerForTopic("question", (msg, m) -> m.createAnswerMsg()); - - m1.sendMessage(new Msg("not asdf", "will it stuck", "uahh", 10000)); - Thread.sleep(10000); - - Msg answer = m1.sendAndAwaitFirstAnswer(new Msg("question", "question", "a value"), 10000); - assertNotNull(answer); - ; - m1.terminate(); - m2.terminate(); - - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void answerTestDifferentType(Morphium morphium) throws Exception { - MorphiumMessaging sender = morphium.createMessaging().setPause(100).setMultithreadded(true); - MorphiumMessaging recipient = morphium.createMessaging().setPause(100).setMultithreadded(true); - gotMessage1 = false; - recipient.addListenerForTopic("query", new MessageListener() { - @Override - public Msg onMessage(MorphiumMessaging msg, Msg m) { - gotMessage1 = true; - Msg answer = m.createAnswerMsg(); - answer.setTopic("queryAnswer"); - answer.setMsg("the answer"); - //msg.storeMessage(answer); - return answer; - } - }); - gotMessage2 = false; - sender.addListenerForTopic("queryAnswer", new MessageListener() { - @Override - public Msg onMessage(MorphiumMessaging msg, Msg m) { - gotMessage2 = true; - assertNotNull(m.getInAnswerTo()); - ; - return null; - } - }); - Thread.sleep(1000); // Allow topic listeners to register - - sender.setUseChangeStream(false).start(); - recipient.setUseChangeStream(false).start(); - assertTrue(sender.waitForReady(30, TimeUnit.SECONDS), "sender not ready"); - assertTrue(recipient.waitForReady(30, TimeUnit.SECONDS), "recipient not ready"); - Thread.sleep(1000); // Allow listener registration - - sender.sendMessage(new Msg("query", "a query", "avalue")); - TestUtils.waitForConditionToBecomeTrue(5000, "Messages not received", () -> gotMessage1 && gotMessage2); - assert (gotMessage1); - assert (gotMessage2); - - Msg answer = sender.sendAndAwaitFirstAnswer(new Msg("query", "query", "avalue"), 1000); - assertNotNull(answer); - ; - sender.terminate(); - recipient.terminate(); - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void sendAndWaitforAnswerTestFailing(Morphium morphium) { - // When sending a message to yourself (without using sendMessageToSelf), - // you should NOT receive it, so this should timeout - assertThrows(RuntimeException.class, ()-> { - MorphiumMessaging m1 = morphium.createMessaging().setPause(100).setMultithreadded(false); - log.info("Upcoming Errormessage is expected!"); - try { - m1.addListenerForTopic("test", (msg, m) -> { - gotMessage1 = true; - return new Msg(m.getTopic(), "got message", "value", 5000); - }); - - m1.setUseChangeStream(false).start(); - assertTrue(m1.waitForReady(30, TimeUnit.SECONDS), "m1 not ready"); - - Msg answer = m1.sendAndAwaitFirstAnswer(new Msg("test", "Sender", "sent", 5000), 500); - } finally { - //cleaning up - m1.terminate(); - morphium.dropCollection(Msg.class); - } - }); - - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void sendAndWaitforAnswerTest(Morphium morphium) throws Exception { -// morphium.dropCollection(Msg.class); - MorphiumMessaging sender = morphium.createMessaging().setPause(100).setMultithreadded(false); - - gotMessage1 = false; - gotMessage2 = false; - gotMessage3 = false; - gotMessage4 = false; - - MorphiumMessaging m1 = morphium.createMessaging().setPause(100).setMultithreadded(false); - m1.addListenerForTopic("test", (msg, m) -> { - gotMessage1 = true; - return new Msg(m.getTopic(), "got message", "value", 5000); - }); - - sender.setUseChangeStream(false).start(); - m1.setUseChangeStream(false).start(); - assertTrue(sender.waitForReady(30, TimeUnit.SECONDS), "sender not ready"); - assertTrue(m1.waitForReady(30, TimeUnit.SECONDS), "m1 not ready"); - Thread.sleep(1000); // Allow listener registration - - Msg answer = sender.sendAndAwaitFirstAnswer(new Msg("test", "Sender", "sent", 15000), 15000); - assertNotNull(answer); - ; - assert (answer.getTopic().equals("test")); - assertNotNull(answer.getInAnswerTo()); - ; - assertNotNull(answer.getRecipients()); - ; - assert (answer.getMsg().equals("got message")); - m1.terminate(); - sender.terminate(); - } - - -} diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/ncmessaging/BigMessagesNCTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/ncmessaging/BigMessagesNCTest.java deleted file mode 100644 index d81fd3917..000000000 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/ncmessaging/BigMessagesNCTest.java +++ /dev/null @@ -1,71 +0,0 @@ -package de.caluga.test.mongo.suite.ncmessaging; -import de.caluga.test.mongo.suite.base.MultiDriverTestBase; - -import de.caluga.morphium.UtilsMap; -import de.caluga.morphium.messaging.Msg; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; - -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; -import de.caluga.morphium.Morphium; - -@Disabled -@Tag("messaging") -public class BigMessagesNCTest extends MultiDriverTestBase { - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void testBigMessage(Morphium morphium) throws Exception { - final AtomicInteger count = new AtomicInteger(); - morphium.dropCollection(Msg.class, "msg", null); - Thread.sleep(1000); - var sender = morphium.createMessaging().setPause(100).setMultithreadded(true).setWindowSize(10); - var receiver = morphium.createMessaging(); - - try { - sender.setUseChangeStream(false).start(); - receiver.setUseChangeStream(false).start(); - receiver.addListenerForTopic("bigMsg", (msg, m) -> { - long dur = System.currentTimeMillis() - m.getTimestamp(); - long dur2 = System.currentTimeMillis() - (Long) m.getMapValue().get("ts"); - log.info("Received #" + m.getMapValue().get("msgNr") + " after " + dur + "ms Dur2: " + dur2); - count.incrementAndGet(); - return null; - }); - int amount = 25; - - for (int i = 0; i < amount; i++) { - StringBuilder txt = new StringBuilder(); - txt.append("Test"); - - for (int t = 0; t < 6 * Math.random() + 5; t++) { - txt.append(txt.toString() + "/" + txt.toString()); - } - - log.info("Text Size: " + txt.length()); - Msg big = new Msg(); - big.setTopic("bigMsg"); - big.setTtl(3000000); - big.setValue(txt.toString()); - big.setMapValue(UtilsMap.of("msgNr", i)); - big.getMapValue().put("ts", System.currentTimeMillis()); - big.setTimestamp(System.currentTimeMillis()); - sender.sendMessage(big); - } - - while (count.get() < amount) { - if (count.get() % 10 == 0) { - log.info("still waiting... messages recieved: " + count.get()); - } - - Thread.sleep(500); - } - } finally { - sender.terminate(); - receiver.terminate(); - } - } -} diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/ncmessaging/MessagingNCTest.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/ncmessaging/MessagingNCTest.java deleted file mode 100644 index 234369493..000000000 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/ncmessaging/MessagingNCTest.java +++ /dev/null @@ -1,2077 +0,0 @@ -package de.caluga.test.mongo.suite.ncmessaging; -import de.caluga.test.mongo.suite.base.MultiDriverTestBase; - -import de.caluga.morphium.*; -import de.caluga.morphium.driver.MorphiumId; -import de.caluga.morphium.config.MessagingSettings; -import de.caluga.morphium.messaging.*; -import de.caluga.morphium.query.Query; -import de.caluga.test.mongo.suite.base.TestUtils; - -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; - -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.jupiter.api.Assertions.*; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; -import de.caluga.morphium.Morphium; - -/** - * User: Stephan Bösebeck - * Date: 26.05.12 - * Time: 17:34 - *

- */ -@SuppressWarnings("unchecked") -@Disabled -@Tag("messaging") -public class MessagingNCTest extends MultiDriverTestBase { - private final List list = new ArrayList<>(); - private final AtomicInteger queueCount = new AtomicInteger(1000); - public boolean gotMessage = false; - public boolean gotMessage1 = false; - public boolean gotMessage2 = false; - public boolean gotMessage3 = false; - public boolean gotMessage4 = false; - public boolean error = false; - public MorphiumId lastMsgId; - public AtomicInteger procCounter = new AtomicInteger(0); - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void testMsgQueName(Morphium morphium) throws Exception { - morphium.dropCollection(Msg.class); - morphium.dropCollection(Msg.class, "mmsg_msg2", null); - - SingleCollectionMessaging m = createMsg(morphium, 100, true); - m.addListenerForTopic("test", (msg, m1) -> { - gotMessage1 = true; - return null; - }); - m.setUseChangeStream(false).start(); - - SingleCollectionMessaging m2 = createMsg(morphium, "msg2", 100, true); - m2.addListenerForTopic("test", (msg, m1) -> { - gotMessage2 = true; - return null; - }); - m2.setUseChangeStream(false).start(); - try { - Msg msg = new Msg("test", "msg", "value", 30000); - msg.setExclusive(false); - m.sendMessage(msg); - Thread.sleep(200); - Query q = morphium.createQueryFor(Msg.class); - assert (q.countAll() == 1) : "Count wrong: " + q.countAll() + " - should be 1!"; - q.setCollectionName(m2.getCollectionName()); - assert (q.countAll() == 0); - - msg = new Msg("test", "msg", "value", 30000); - msg.setExclusive(false); - m2.sendMessage(msg); - Thread.sleep(600); - q = morphium.createQueryFor(Msg.class); - assert (q.countAll() == 1); - q.setCollectionName("mmsg_msg2"); - assert (q.countAll() == 1) : "Count is " + q.countAll(); - - Thread.sleep(4000); - assert (!gotMessage1); - assert (!gotMessage2); - } finally { - m.terminate(); - m2.terminate(); - } - - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void testMsgLifecycle(Morphium morphium) throws Exception { - Msg m = new Msg(); - m.setSender("Meine wunderbare ID " + System.currentTimeMillis()); - m.setMsgId(new MorphiumId()); - m.setTopic("A name"); - morphium.store(m); - Thread.sleep(5000); - assert (m.getTimestamp() > 0) : "Timestamp not updated?"; - - } - - @SuppressWarnings("Duplicates") - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void multithreaddingTestSingle(Morphium morphium) throws Exception { - int amount = 65; - SingleCollectionMessaging producer = createMsg(morphium, 500, false); - producer.start(); - for (int i = 0; i < amount; i++) { - if (i % 10 == 0) { - log.info("Messages sent: " + i); - } - Msg m = new Msg("test", "tm", "" + i + System.currentTimeMillis(), 30000); - producer.sendMessage(m); - } - final AtomicInteger count = new AtomicInteger(); - SingleCollectionMessaging consumer = createMsg(morphium, 100, false, true, 1000); - consumer.addListenerForTopic("test", (msg, m) -> { -// log.info("Got message!"); - count.incrementAndGet(); - return null; - }); - long start = System.currentTimeMillis(); - consumer.setUseChangeStream(false).start(); - while (count.get() < amount) { - log.info("Messages processed: " + count.get()); - Thread.sleep(1000); - if (System.currentTimeMillis() - start > 20000) throw new RuntimeException("Timeout"); - } - long dur = System.currentTimeMillis() - start; - log.info("processing " + amount + " multithreaded but single messages took " + dur + "ms == " + (amount / (dur / 1000)) + " msg/sec"); - - consumer.terminate(); - producer.terminate(); - - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void mutlithreaddingTestMultiple(Morphium morphium) throws Exception { - int amount = 650; - SingleCollectionMessaging producer = createMsg(morphium, 500, false); - producer.start(); - log.info("now multithreadded and multiprocessing"); - for (int i = 0; i < amount; i++) { - if (i % 10 == 0) { - log.info("Messages sent: " + i); - } - Msg m = new Msg("test", "tm", "" + i + System.currentTimeMillis(), 30000); - producer.sendMessage(m); - } - final AtomicInteger count = new AtomicInteger(); - count.set(0); - SingleCollectionMessaging consumer = createMsg(morphium, 100, true, true, 100); - consumer.addListenerForTopic("test", (msg, m) -> { -// log.info("Got message!"); - count.incrementAndGet(); - return null; - }); - long start = System.currentTimeMillis(); - consumer.setUseChangeStream(false).start(); - while (count.get() < amount) { - log.info("Messages processed: " + count.get()); - Thread.sleep(1000); - if (System.currentTimeMillis() - start > 20000) throw new RuntimeException("Timeout!"); - } - long dur = System.currentTimeMillis() - start; - log.info("processing 2500 multithreaded and multiprocessing messages took " + dur + "ms == " + (2500 / (dur / 1000)) + " msg/sec"); - - - consumer.terminate(); - producer.terminate(); - log.info("Messages processed: " + count.get()); - log.info("Messages left: " + consumer.getPendingMessagesCount()); - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void messagingTest(Morphium morphium) throws Exception { - error = false; - - morphium.dropCollection(Msg.class); - - final SingleCollectionMessaging messaging = createMsg(morphium, 100, true); - try { - messaging.setUseChangeStream(false).start(); - Thread.sleep(500); - - messaging.addListenerForTopic("test", (msg, m) -> { - log.info("Got Message: " + m.toString()); - gotMessage = true; - return null; - }); - messaging.sendMessage(new Msg("test", "A message", "the value - for now", 5000000)); - - Thread.sleep(1000); - assert (!gotMessage) : "Message recieved from self?!?!?!"; - log.info("Dig not get own message - cool!"); - - Msg m = new Msg("test", "The Message", "value is a string", 5000000); - m.setMsgId(new MorphiumId()); - m.setSender("Another sender"); - - morphium.store(m, messaging.getCollectionName(), null); - - long start = System.currentTimeMillis(); - while (!gotMessage) { - Thread.sleep(100); - assert (System.currentTimeMillis() - start < 5000) : " Message did not come?!?!?"; - } - assert (gotMessage); - gotMessage = false; - Thread.sleep(200); - assert (!gotMessage) : "Got message again?!?!?!"; - } finally { - messaging.terminate(); - Thread.sleep(200); - assert (!messaging.isAlive()) : "Messaging still running?!?"; - } - - - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void systemTest(Morphium morphium) throws Exception { - morphium.dropCollection(Msg.class); - gotMessage1 = false; - gotMessage2 = false; - gotMessage3 = false; - gotMessage4 = false; - error = false; - - morphium.clearCollection(Msg.class); - final SingleCollectionMessaging m1 = createMsg(morphium, 100, true); - final SingleCollectionMessaging m2 = createMsg(morphium, 100, true); - try { - m1.setUseChangeStream(false).start(); - m2.setUseChangeStream(false).start(); - Thread.sleep(100); - m1.addListenerForTopic("test", (msg, m) -> { - gotMessage1 = true; - log.info("M1 got message " + m.toString()); - if (!m.getSender().equals(m2.getSenderId())) { - log.error("Sender is not M2?!?!? m2_id: " + m2.getSenderId() + " - message sender: " + m.getSender()); - error = true; - } - return null; - }); - - m2.addListenerForTopic("test", (msg, m) -> { - gotMessage2 = true; - log.info("M2 got message " + m.toString()); - if (!m.getSender().equals(m1.getSenderId())) { - log.error("Sender is not M1?!?!? m1_id: " + m1.getSenderId() + " - message sender: " + m.getSender()); - error = true; - } - return null; - }); - - m1.sendMessage(new Msg("test", "The message from M1", "Value")); - Thread.sleep(1000); - assert (gotMessage2) : "Message not recieved yet?!?!?"; - gotMessage2 = false; - - m2.sendMessage(new Msg("test", "The message from M2", "Value")); - Thread.sleep(1000); - assert (gotMessage1) : "Message not recieved yet?!?!?"; - gotMessage1 = false; - assert (!error); - } finally { - m1.terminate(); - m2.terminate(); - Thread.sleep(200); - assert (!m1.isAlive()) : "m1 still running?"; - assert (!m2.isAlive()) : "m2 still running?"; - } - - - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void severalSystemsTest(Morphium morphium) throws Exception { - morphium.clearCollection(Msg.class); - gotMessage1 = false; - gotMessage2 = false; - gotMessage3 = false; - gotMessage4 = false; - error = false; - - - final SingleCollectionMessaging m1 = createMsg(morphium, 10, true); - final SingleCollectionMessaging m2 = createMsg(morphium, 10, true); - final SingleCollectionMessaging m3 = createMsg(morphium, 10, true); - final SingleCollectionMessaging m4 = createMsg(morphium, 10, true); - - try { - m4.setUseChangeStream(false).start(); - m1.setUseChangeStream(false).start(); - m2.setUseChangeStream(false).start(); - m3.setUseChangeStream(false).start(); - Thread.sleep(200); - - m1.addListenerForTopic("test", (msg, m) -> { - gotMessage1 = true; - log.info("M1 got message " + m.toString()); - return null; - }); - - m2.addListenerForTopic("test", (msg, m) -> { - gotMessage2 = true; - log.info("M2 got message " + m.toString()); - return null; - }); - - m3.addListenerForTopic("test", (msg, m) -> { - gotMessage3 = true; - log.info("M3 got message " + m.toString()); - return null; - }); - - m4.addListenerForTopic("test", (msg, m) -> { - gotMessage4 = true; - log.info("M4 got message " + m.toString()); - return null; - }); - - m1.sendMessage(new Msg("test", "The message from M1", "Value")); - Thread.sleep(500); - assert (gotMessage2) : "Message not recieved yet by m2?!?!?"; - assert (gotMessage3) : "Message not recieved yet by m3?!?!?"; - assert (gotMessage4) : "Message not recieved yet by m4?!?!?"; - gotMessage1 = false; - gotMessage2 = false; - gotMessage3 = false; - gotMessage4 = false; - - m2.sendMessage(new Msg("test", "The message from M2", "Value")); - Thread.sleep(500); - assert (gotMessage1) : "Message not recieved yet by m1?!?!?"; - assert (gotMessage3) : "Message not recieved yet by m3?!?!?"; - assert (gotMessage4) : "Message not recieved yet by m4?!?!?"; - - - gotMessage1 = false; - gotMessage2 = false; - gotMessage3 = false; - gotMessage4 = false; - - m1.sendMessage(new Msg("test", "This is the message", "value", 30000000, true)); - Thread.sleep(500); - int cnt = 0; - if (gotMessage1) cnt++; - if (gotMessage2) cnt++; - if (gotMessage3) cnt++; - if (gotMessage4) cnt++; - - - Thread.sleep(1000); - - assert (cnt != 0) : "Message was not received"; - assert (cnt == 1) : "Message was received too often: " + cnt; - } finally { - m1.terminate(); - m2.terminate(); - m3.terminate(); - m4.terminate(); - Thread.sleep(200); - assert (!m1.isAlive()) : "M1 still running"; - assert (!m2.isAlive()) : "M2 still running"; - assert (!m3.isAlive()) : "M3 still running"; - assert (!m4.isAlive()) : "M4 still running"; - } - - - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void testRejectExclusiveMessage(Morphium morphium) throws Exception { - SingleCollectionMessaging sender = null; - SingleCollectionMessaging rec1 = null; - SingleCollectionMessaging rec2 = null; - try { - sender = createMsg(morphium, 100, false); - sender.setSenderId("sender"); - rec1 = createMsg(morphium, 100, false); - rec1.setSenderId("rec1"); - rec2 = createMsg(morphium, 100, false); - rec2.setSenderId("rec2"); - morphium.dropCollection(Msg.class, sender.getCollectionName(), null); - Thread.sleep(10); - sender.setUseChangeStream(false).start(); - rec1.setUseChangeStream(false).start(); - rec2.setUseChangeStream(false).start(); - Thread.sleep(2000); - final AtomicInteger recFirst = new AtomicInteger(0); - - gotMessage = false; - - rec1.addListenerForTopic("test", (msg, m) -> { - if (recFirst.get() == 0) { - recFirst.set(1); - throw new MessageRejectedException("rejected", true, true); - } - gotMessage = true; - return null; - }); - rec2.addListenerForTopic("test", (msg, m) -> { - if (recFirst.get() == 0) { - recFirst.set(1); - throw new MessageRejectedException("rejected", true, true); - } - gotMessage = true; - return null; - }); - sender.addListenerForTopic("test", (msg, m) -> { - if (m.getInAnswerTo() == null) { - log.error("Message is not an answer! ERROR!"); - return null; - } else { - log.info("Got answer"); - } - gotMessage3 = true; - log.info("Receiver " + m.getSender() + " rejected message"); - return null; - }); - - - sender.sendMessage(new Msg("test", "message", "value", 3000000, true)); - TestUtils.waitForConditionToBecomeTrue(5000, "did not getMessage at all!", ()-> gotMessage && gotMessage3); - } finally { - sender.terminate(); - rec1.terminate(); - rec2.terminate(); - } - - - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void testRejectMessage(Morphium morphium) throws Exception { - SingleCollectionMessaging sender = null; - SingleCollectionMessaging rec1 = null; - SingleCollectionMessaging rec2 = null; - try { - sender = createMsg(morphium, 100, false); - rec1 = createMsg(morphium, 100, false); - rec2 = createMsg(morphium, 500, false); - morphium.dropCollection(Msg.class, sender.getCollectionName(), null); - Thread.sleep(10); - sender.setUseChangeStream(false).start(); - rec1.setUseChangeStream(false).start(); - rec2.setUseChangeStream(false).start(); - Thread.sleep(2000); - gotMessage1 = false; - gotMessage2 = false; - gotMessage3 = false; - - rec1.addListenerForTopic("test", (msg, m) -> { - gotMessage1 = true; - throw new MessageRejectedException("rejected", true, true); - }); - rec2.addListenerForTopic("test", (msg, m) -> { - gotMessage2 = true; - log.info("Processing message " + m.getValue()); - return null; - }); - sender.addListenerForTopic("test", (msg, m) -> { - if (m.getInAnswerTo() == null) { - log.error("Message is not an answer! ERROR!"); - return null; - } - gotMessage3 = true; - log.info("Receiver rejected message"); - return null; - }); - - sender.sendMessage(new Msg("test", "message", "value")); - - Thread.sleep(1000); - assert (gotMessage1); - assert (gotMessage2); - assert (gotMessage3); - } finally { - sender.terminate(); - rec1.terminate(); - rec2.terminate(); - } - - - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void directedMessageTest(Morphium morphium) throws Exception { - morphium.clearCollection(Msg.class); - final SingleCollectionMessaging m1; - final SingleCollectionMessaging m2; - final SingleCollectionMessaging m3; - m1 = createMsg(morphium, 100, true); - m2 = createMsg(morphium, 100, true); - m3 = createMsg(morphium, 100, true); - try { - - m1.setUseChangeStream(false).start(); - m2.setUseChangeStream(false).start(); - m3.setUseChangeStream(false).start(); - Thread.sleep(2500); - gotMessage1 = false; - gotMessage2 = false; - gotMessage3 = false; - gotMessage4 = false; - - log.info("m1 ID: " + m1.getSenderId()); - log.info("m2 ID: " + m2.getSenderId()); - log.info("m3 ID: " + m3.getSenderId()); - - m1.addListenerForTopic("test", (msg, m) -> { - gotMessage1 = true; - if (m.getTo() != null && !m.getTo().contains(m1.getSenderId())) { - log.error("wrongly received message?"); - error = true; - } - log.info("DM-M1 got message " + m.toString()); - // assert (m.getSender().equals(m2.getSenderId())) : "Sender is not M2?!?!? m2_id: " + m2.getSenderId() + " - message sender: " + m.getSender(); - return null; - }); - - m2.addListenerForTopic("test", (msg, m) -> { - gotMessage2 = true; - assert (m.getTo() == null || m.getTo().contains(m2.getSenderId())) : "wrongly received message?"; - log.info("DM-M2 got message " + m.toString()); - // assert (m.getSender().equals(m1.getSenderId())) : "Sender is not M1?!?!? m1_id: " + m1.getSenderId() + " - message sender: " + m.getSender(); - return null; - }); - - m3.addListenerForTopic("test", (msg, m) -> { - gotMessage3 = true; - assert (m.getTo() == null || m.getTo().contains(m3.getSenderId())) : "wrongly received message?"; - log.info("DM-M3 got message " + m.toString()); - // assert (m.getSender().equals(m1.getSenderId())) : "Sender is not M1?!?!? m1_id: " + m1.getSenderId() + " - message sender: " + m.getSender(); - return null; - }); - - //sending message to all - log.info("Sending broadcast message"); - m1.sendMessage(new Msg("test", "The message from M1", "Value")); - Thread.sleep(3000); - assert (gotMessage2) : "Message not recieved yet by m2?!?!?"; - assert (gotMessage3) : "Message not recieved yet by m3?!?!?"; - assert (!error); - gotMessage1 = false; - gotMessage2 = false; - gotMessage3 = false; - error = false; - TestUtils.waitForWrites(morphium, log); - Thread.sleep(2500); - assert (!gotMessage1) : "Message recieved again by m1?!?!?"; - assert (!gotMessage2) : "Message recieved again by m2?!?!?"; - assert (!gotMessage3) : "Message recieved again by m3?!?!?"; - assert (!error); - - log.info("Sending direct message"); - Msg m = new Msg("test", "The message from M1", "Value"); - m.addRecipient(m2.getSenderId()); - m1.sendMessage(m); - Thread.sleep(1000); - assert (gotMessage2) : "Message not received by m2?"; - assert (!gotMessage1) : "Message recieved by m1?!?!?"; - assert (!gotMessage3) : "Message recieved again by m3?!?!?"; - gotMessage1 = false; - gotMessage2 = false; - gotMessage3 = false; - error = false; - Thread.sleep(1000); - assert (!gotMessage1) : "Message recieved again by m1?!?!?"; - assert (!gotMessage2) : "Message not recieved again by m2?!?!?"; - assert (!gotMessage3) : "Message not recieved again by m3?!?!?"; - assert (!error); - - log.info("Sending message to 2 recipients"); - log.info("Sending direct message"); - m = new Msg("test", "The message from M1", "Value"); - m.addRecipient(m2.getSenderId()); - m.addRecipient(m3.getSenderId()); - m1.sendMessage(m); - Thread.sleep(1000); - assert (gotMessage2) : "Message not received by m2?"; - assert (!gotMessage1) : "Message recieved by m1?!?!?"; - assert (gotMessage3) : "Message not recieved by m3?!?!?"; - assert (!error); - gotMessage1 = false; - gotMessage2 = false; - gotMessage3 = false; - - Thread.sleep(1000); - assert (!gotMessage1) : "Message recieved again by m1?!?!?"; - assert (!gotMessage2) : "Message not recieved again by m2?!?!?"; - assert (!gotMessage3) : "Message not recieved again by m3?!?!?"; - assert (!error); - } finally { - m1.terminate(); - m2.terminate(); - m3.terminate(); - Thread.sleep(1000); - - } - - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void ignoringMessagesTest(Morphium morphium) throws Exception { - morphium.dropCollection(Msg.class); - Thread.sleep(100); - SingleCollectionMessaging m1 = createMsg(morphium, 10, false, true, 10); - m1.setSenderId("m1"); - SingleCollectionMessaging m2 = createMsg(morphium, 10, false, true, 10); - m2.setSenderId("m2"); - try { - m1.setUseChangeStream(false).start(); - m2.setUseChangeStream(false).start(); - Thread.sleep(250); - Msg m = new Msg("test", "ignore me please", "value"); - m1.sendMessage(m); - Thread.sleep(1000); - m = morphium.reread(m); - assertEquals(0, m.getProcessedBy().size()); //is marked as processed, performance optimization - } finally { - m1.terminate(); - m2.terminate(); - } - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void ignoringExclusiveMessagesTest(Morphium morphium) throws Exception { - morphium.dropCollection(Msg.class); - Thread.sleep(100); - SingleCollectionMessaging m1 = createMsg(morphium, 10, false, true, 10); - m1.setSenderId("m1"); - SingleCollectionMessaging m2 = createMsg(morphium, 10, false, true, 10); - m2.setSenderId("m2"); - SingleCollectionMessaging m3 = createMsg(morphium, 10, false, true, 10); - m3.setSenderId("m3"); - m3.addListenerForTopic("test", new MessageListener() { - @Override - public Msg onMessage(MorphiumMessaging msg, Msg m) { - return null; - } - }); - try { - m1.setUseChangeStream(false).start(); - m2.setUseChangeStream(false).start(); - m3.setUseChangeStream(false).start(); - Thread.sleep(250); - for (int i = 0; i < 10; i++) { - Msg m = new Msg("test", "ignore me please", "value", 2000, true); - m1.sendMessage(m); - Thread.sleep(1000); - m = morphium.reread(m); - assertEquals(1, m.getProcessedBy().size()); - assertTrue(m.getProcessedBy().contains("m3")); - } - } finally { - m1.terminate(); - m2.terminate(); - m3.terminate(); - } - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void severalMessagingsTest(Morphium morphium) throws Exception { - morphium.dropCollection(Msg.class); - Thread.sleep(100); - SingleCollectionMessaging m1 = createMsg(morphium, 10, false, true, 10); - m1.setSenderId("m1"); - SingleCollectionMessaging m2 = createMsg(morphium, 10, false, true, 10); - m2.setSenderId("m2"); - SingleCollectionMessaging m3 = createMsg(morphium, 10, false, true, 10); - m3.setSenderId("m3"); - m1.setUseChangeStream(false).start(); - m2.setUseChangeStream(false).start(); - m3.setUseChangeStream(false).start(); - try { - m3.addListenerForTopic("test", (msg, m) -> { - //log.info("Got message: "+m.getName()); - if (m.getInAnswerTo() != null) { - log.error("Got an answer here?"); - } - log.info("Sending answer for " + m.getMsgId()); - return new Msg("test", "answer", "value", 600000); - }); - - procCounter.set(0); - for (int i = 0; i < 10; i++) { - new Thread() { - public void run() { - Msg m = new Msg("test", "nothing", "value"); - m.setTtl(60000000); - Msg a = m1.sendAndAwaitFirstAnswer(m, 36000); - assertNotNull(a); - ; - procCounter.incrementAndGet(); - } - } .start(); - - } - while (procCounter.get() < 10) { - Thread.sleep(1000); - log.info("Recieved " + procCounter.get()); - } - } finally { - m1.terminate(); - m2.terminate(); - m3.terminate(); - } - - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void massiveMessagingTest(Morphium morphium) throws Exception { - List systems; - systems = new ArrayList<>(); - try { - int numberOfWorkers = 20; - int numberOfMessages = 200; - long ttl = 150000; //15 sec - - final boolean[] failed = {false}; - morphium.clearCollection(Msg.class); - - final Map processedMessages = new Hashtable<>(); - procCounter.set(0); - for (int i = 0; i < numberOfWorkers; i++) { - //creating messaging instances - SingleCollectionMessaging m = createMsg(morphium, 100, true); - m.setUseChangeStream(false).start(); - systems.add(m); - MessageListener l = new MessageListener() { - final List ids = Collections.synchronizedList(new ArrayList<>()); - SingleCollectionMessaging msg; - - @Override - public Msg onMessage(MorphiumMessaging msg, Msg m) { - if (ids.contains(msg.getSenderId() + "/" + m.getMsgId())) failed[0] = true; - assert (!ids.contains(msg.getSenderId() + "/" + m.getMsgId())) : "Re-getting message?!?!? " + m.getMsgId() + " MyId: " + msg.getSenderId(); - ids.add(msg.getSenderId() + "/" + m.getMsgId()); - assert (m.getTo() == null || m.getTo().contains(msg.getSenderId())) : "got message not for me?"; - assert (!m.getSender().equals(msg.getSenderId())) : "Got message from myself?"; - synchronized (processedMessages) { - Integer pr = processedMessages.get(m.getMsgId()); - if (pr == null) { - pr = 0; - } - processedMessages.put(m.getMsgId(), pr + 1); - procCounter.incrementAndGet(); - } - return null; - } - - }; - m.addListenerForTopic("test", l); - } - Thread.sleep(100); - - long start = System.currentTimeMillis(); - for (int i = 0; i < numberOfMessages; i++) { - int m = (int) (Math.random() * systems.size()); - Msg msg = new Msg("test", "The message for msg " + i, "a value", ttl); - msg.addAdditional("Additional Value " + i); - msg.setExclusive(false); - systems.get(m).sendMessage(msg); - } - - long dur = System.currentTimeMillis() - start; - log.info("Queueing " + numberOfMessages + " messages took " + dur + " ms - now waiting for writes.."); - TestUtils.waitForWrites(morphium, log); - log.info("...all messages persisted!"); - int last = 0; - assert (!failed[0]); - Thread.sleep(1000); - //See if whole number of messages processed is correct - //keep in mind: a message is never recieved by the sender, hence numberOfWorkers-1 - while (true) { - if (procCounter.get() == numberOfMessages * (numberOfWorkers - 1)) { - break; - } - if (last == procCounter.get()) { - log.info("No change in procCounter?! somethings wrong..."); - break; - - } - last = procCounter.get(); - log.info("Waiting for messages to be processed - procCounter: " + procCounter.get()); - Thread.sleep(2000); - } - assert (!failed[0]); - Thread.sleep(1000); - log.info("done"); - assert (!failed[0]); - - assert (processedMessages.size() == numberOfMessages) : "sent " + numberOfMessages + " messages, but only " + processedMessages.size() + " were recieved?"; - for (MorphiumId id : processedMessages.keySet()) { - log.info(id + "---- ok!"); - assert (processedMessages.get(id) == numberOfWorkers - 1) : "Message " + id + " was not recieved by all " + (numberOfWorkers - 1) + " other workers? only by " + processedMessages.get(id); - } - assert (procCounter.get() == numberOfMessages * (numberOfWorkers - 1)) : "Still processing messages?!?!?"; - - //Waiting for all messages to be outdated and deleted - } finally { - //Stopping all - for (SingleCollectionMessaging m : systems) { - m.terminate(); - } - Thread.sleep(1000); - for (SingleCollectionMessaging m : systems) { - assert (!m.isAlive()) : "Thread still running?"; - } - - } - - - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void broadcastTest(Morphium morphium) throws Exception { - morphium.clearCollection(Msg.class); - final SingleCollectionMessaging m1 = createMsg(morphium, 1000, true); - final SingleCollectionMessaging m2 = createMsg(morphium, 10, true); - final SingleCollectionMessaging m3 = createMsg(morphium, 10, true); - final SingleCollectionMessaging m4 = createMsg(morphium, 10, true); - gotMessage1 = false; - gotMessage2 = false; - gotMessage3 = false; - gotMessage4 = false; - error = false; - - m4.setUseChangeStream(false).start(); - m1.setUseChangeStream(false).start(); - m3.setUseChangeStream(false).start(); - m2.setUseChangeStream(false).start(); - Thread.sleep(300); - try { - log.info("m1 ID: " + m1.getSenderId()); - log.info("m2 ID: " + m2.getSenderId()); - log.info("m3 ID: " + m3.getSenderId()); - - m1.addListenerForTopic("test", (msg, m) -> { - gotMessage1 = true; - if (m.getTo() != null && m.getTo().contains(m1.getSenderId())) { - log.error("wrongly received message m1?"); - error = true; - } - log.info("M1 got message " + m.toString()); - return null; - }); - - m2.addListenerForTopic("test", (msg, m) -> { - gotMessage2 = true; - if (m.getTo() != null && !m.getTo().contains(m2.getSenderId())) { - log.error("wrongly received message m2?"); - error = true; - } - log.info("M2 got message " + m.toString()); - return null; - }); - - m3.addListenerForTopic("test", (msg, m) -> { - gotMessage3 = true; - if (m.getTo() != null && !m.getTo().contains(m3.getSenderId())) { - log.error("wrongly received message m3?"); - error = true; - } - log.info("M3 got message " + m.toString()); - return null; - }); - m4.addListenerForTopic("test", (msg, m) -> { - gotMessage4 = true; - if (m.getTo() != null && !m.getTo().contains(m3.getSenderId())) { - log.error("wrongly received message m4?"); - error = true; - } - log.info("M4 got message " + m.toString()); - return null; - }); - - Msg m = new Msg("test", "A message", "a value"); - m.setExclusive(false); - m1.sendMessage(m); - - while (!gotMessage2 || !gotMessage3 || !gotMessage4) { - Thread.sleep(500); - } - assert (!gotMessage1) : "Got message again?"; - assert (gotMessage4) : "m4 did not get msg?"; - assert (gotMessage2) : "m2 did not get msg?"; - assert (gotMessage3) : "m3 did not get msg"; - assert (!error); - gotMessage2 = false; - gotMessage3 = false; - gotMessage4 = false; - Thread.sleep(500); - assert (!gotMessage1) : "Got message again?"; - assert (!gotMessage2) : "m2 did get msg again?"; - assert (!gotMessage3) : "m3 did get msg again?"; - assert (!gotMessage4) : "m4 did get msg again?"; - assert (!error); - } finally { - m1.terminate(); - m2.terminate(); - m3.terminate(); - m4.terminate(); - } - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void messagingSendReceiveThreaddedTest(Morphium morphium) throws Exception { - morphium.dropCollection(Msg.class); - Thread.sleep(2500); - final SingleCollectionMessaging producer = createMsg(morphium, 100, true, false, 10); - final SingleCollectionMessaging consumer = createMsg(morphium, 100, true, true, 2000); - producer.setUseChangeStream(false).start(); - consumer.setUseChangeStream(false).start(); - try { - Vector processedIds = new Vector<>(); - procCounter.set(0); - consumer.addListenerForTopic("test", (msg, m) -> { - procCounter.incrementAndGet(); - if (processedIds.contains(m.getMsgId().toString())) { - log.error("Received msg twice: " + procCounter.get() + "/" + m.getMsgId()); - return null; - } - processedIds.add(m.getMsgId().toString()); - //simulate processing - try { - Thread.sleep((long) (100 * Math.random())); - } catch (InterruptedException e) { - - } - return null; - }); - Thread.sleep(2500); - int amount = 1000; - log.info("------------- sending messages"); - for (int i = 0; i < amount; i++) { - producer.sendMessage(new Msg("test", "msg " + i, "value " + i)); - } - - for (int i = 0; i < 30 && procCounter.get() < amount; i++) { - Thread.sleep(1000); - log.info("Still processing: " + procCounter.get()); - } - assert (procCounter.get() == amount) : "Did process " + procCounter.get(); - } finally { - producer.terminate(); - consumer.terminate(); - } - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void messagingSendReceiveTest(Morphium morphium) throws Exception { - morphium.dropCollection(Msg.class); - Thread.sleep(100); - final SingleCollectionMessaging producer = createMsg(morphium, 100, true); - final SingleCollectionMessaging consumer = createMsg(morphium, 10, true); - producer.setUseChangeStream(false).start(); - consumer.setUseChangeStream(false).start(); - Thread.sleep(2500); - try { - final int[] processed = {0}; - final Vector messageIds = new Vector<>(); - consumer.addListenerForTopic("test", (msg, m) -> { - processed[0]++; - if (processed[0] % 50 == 1) { - log.info(processed[0] + "... Got Message " + m.getTopic() + " / " + m.getMsg() + " / " + m.getValue()); - } - assert (!messageIds.contains(m.getMsgId().toString())) : "Duplicate message: " + processed[0]; - messageIds.add(m.getMsgId().toString()); - //simulate processing - try { - Thread.sleep((long) (10 * Math.random())); - } catch (InterruptedException e) { - - } - return null; - }); - - int amount = 1000; - - for (int i = 0; i < amount; i++) { - producer.sendMessage(new Msg("test", "msg " + i, "value " + i)); - } - - for (int i = 0; i < 30 && processed[0] < amount; i++) { - log.info("Still processing: " + processed[0]); - Thread.sleep(1000); - } - assert (processed[0] == amount) : "Did process " + processed[0]; - } finally { - producer.terminate(); - consumer.terminate(); - } - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void mutlithreaddedMessagingPerformanceTest(Morphium morphium) throws Exception { - morphium.clearCollection(Msg.class); - final SingleCollectionMessaging producer = createMsg(morphium, 100, true); - final SingleCollectionMessaging consumer = createMsg(morphium, 10, true, true, 2000); - consumer.setUseChangeStream(false).start(); - producer.setUseChangeStream(false).start(); - Thread.sleep(2500); - try { - final AtomicInteger processed = new AtomicInteger(); - final Map msgCountById = new ConcurrentHashMap<>(); - consumer.addListenerForTopic("test", (msg, m) -> { - processed.incrementAndGet(); - if (processed.get() % 1000 == 0) { - log.info("Consumed " + processed.get()); - } - assert (!msgCountById.containsKey(m.getMsgId().toString())); - msgCountById.putIfAbsent(m.getMsgId().toString(), new AtomicInteger()); - msgCountById.get(m.getMsgId().toString()).incrementAndGet(); - //simulate processing - try { - Thread.sleep((long) (10 * Math.random())); - } catch (InterruptedException e) { - e.printStackTrace(); - } - return null; - }); - - int numberOfMessages = 1000; - for (int i = 0; i < numberOfMessages; i++) { - Msg m = new Msg("test", "m", "v"); - m.setTtl(5 * 60 * 1000); - if (i % 1000 == 0) { - log.info("created msg " + i + " / " + numberOfMessages); - } - producer.sendMessage(m); - } - - long start = System.currentTimeMillis(); - - while (processed.get() < numberOfMessages) { - // ThreadMXBean thbean = ManagementFactory.getThreadMXBean(); - // log.info("Running threads: " + thbean.getThreadCount()); - log.info("Processed " + processed.get()); - Thread.sleep(1500); - } - long dur = System.currentTimeMillis() - start; - log.info("Processing took " + dur + " ms"); - - assert (processed.get() == numberOfMessages); - for (String id : msgCountById.keySet()) { - assert (msgCountById.get(id).get() == 1); - } - } finally { - producer.terminate(); - consumer.terminate(); - } - - - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void exclusiveMessageCustomQueueTest(Morphium morphium) throws Exception { - SingleCollectionMessaging sender = null; - SingleCollectionMessaging sender2 = null; - SingleCollectionMessaging m1 = null; - SingleCollectionMessaging m2 = null; - SingleCollectionMessaging m3 = null; - SingleCollectionMessaging m4 = null; - try { - morphium.dropCollection(Msg.class); - - sender = createMsg(morphium, "test", 100, false); - sender.setSenderId("sender1"); - morphium.dropCollection(Msg.class, sender.getCollectionName(), null); - sender.setUseChangeStream(false).start(); - sender2 = createMsg(morphium, "test2", 100, false); - sender2.setSenderId("sender2"); - morphium.dropCollection(Msg.class, sender2.getCollectionName(), null); - sender2.setUseChangeStream(false).start(); - Thread.sleep(200); - gotMessage1 = false; - gotMessage2 = false; - gotMessage3 = false; - gotMessage4 = false; - - m1 = createMsg(morphium, "test", 100, false); - m1.setSenderId("m1"); - m1.addListenerForTopic("test", (msg, m) -> { - gotMessage1 = true; - log.info("Got message m1"); - return null; - }); - m2 = createMsg(morphium, "test", 100, false); - m2.setSenderId("m2"); - m2.addListenerForTopic("test", (msg, m) -> { - gotMessage2 = true; - log.info("Got message m2"); - return null; - }); - m3 = createMsg(morphium, "test2", 100, false); - m3.setSenderId("m3"); - m3.addListenerForTopic("test", (msg, m) -> { - gotMessage3 = true; - log.info("Got message m3"); - return null; - }); - m4 = createMsg(morphium, "test2", 100, false); - m4.setSenderId("m4"); - m4.addListenerForTopic("test", (msg, m) -> { - gotMessage4 = true; - log.info("Got message m4"); - return null; - }); - - m1.setUseChangeStream(false).start(); - m2.setUseChangeStream(false).start(); - m3.setUseChangeStream(false).start(); - m4.setUseChangeStream(false).start(); - Thread.sleep(200); - Msg m = new Msg(); - m.setExclusive(true); - m.setTtl(3000000); - m.setTopic("A message"); - - sender.sendMessage(m); - - assert (!gotMessage3); - assert (!gotMessage4); - Thread.sleep(1200); - - int rec = 0; - if (gotMessage1) { - rec++; - } - if (gotMessage2) { - rec++; - } - assert (rec == 1) : "rec is " + rec; - - gotMessage1 = false; - gotMessage2 = false; - - m = new Msg(); - m.setExclusive(true); - m.setTopic("A message"); - m.setTtl(3000000); - sender2.sendMessage(m); - Thread.sleep(1500); - assert (!gotMessage1); - assert (!gotMessage2); - - rec = 0; - if (gotMessage3) { - rec++; - } - if (gotMessage4) { - rec++; - } - assert (rec == 1) : "rec is " + rec; - Thread.sleep(2500); - - for (SingleCollectionMessaging ms : Arrays.asList(m1, m2, m3)) { - if (ms.getNumberOfMessages() > 0) { - Query q1 = morphium.createQueryFor(Msg.class, ms.getCollectionName()); - q1.f(Msg.Fields.sender).ne(ms.getSenderId()); - q1.f(Msg.Fields.processedBy).ne(ms.getSenderId()); - List ret = q1.asList(); - for (Msg f : ret) { - log.info("Found elements for " + ms.getSenderId() + ": " + f.toString()); - } - } - } - for (SingleCollectionMessaging ms : Arrays.asList(m1, m2, m3)) { - assert (ms.getNumberOfMessages() == 0) : "Number of messages " + ms.getSenderId() + " is " + ms.getNumberOfMessages(); - } - } finally { - m1.terminate(); - m2.terminate(); - m3.terminate(); - m4.terminate(); - sender.terminate(); - sender2.terminate(); - - } - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void exclusiveMessageTest(Morphium morphium) throws Exception { - morphium.dropCollection(Msg.class); - SingleCollectionMessaging sender = createMsg(morphium, 100, false); - sender.setUseChangeStream(false).start(); - - gotMessage1 = false; - gotMessage2 = false; - gotMessage3 = false; - gotMessage4 = false; - - SingleCollectionMessaging m1 = createMsg(morphium, 100, false); - m1.addListenerForTopic("test", (msg, m) -> { - gotMessage1 = true; - return null; - }); - SingleCollectionMessaging m2 = createMsg(morphium, 100, false); - m2.addListenerForTopic("test", (msg, m) -> { - gotMessage2 = true; - return null; - }); - SingleCollectionMessaging m3 = createMsg(morphium, 100, false); - m3.addListenerForTopic("test", (msg, m) -> { - gotMessage3 = true; - return null; - }); - - m1.setUseChangeStream(false).start(); - m2.setUseChangeStream(false).start(); - m3.setUseChangeStream(false).start(); - try { - Thread.sleep(100); - - - Msg m = new Msg(); - m.setExclusive(true); - m.setTopic("test"); - - sender.queueMessage(m); - Thread.sleep(5000); - - int rec = 0; - if (gotMessage1) { - rec++; - } - if (gotMessage2) { - rec++; - } - if (gotMessage3) { - rec++; - } - assert (rec == 1) : "rec is " + rec; - - assert (m1.getNumberOfMessages() == 0); - } finally { - m1.terminate(); - m2.terminate(); - m3.terminate(); - sender.terminate(); - } - - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void removeMessageTest(Morphium morphium) throws Exception { - SingleCollectionMessaging m1 = createMsg(morphium, 1000, false); - try { - Msg m = new Msg().setMsgId(new MorphiumId()).setMsg("msg").setTopic("name").setValue("a value"); - m1.sendMessage(m); - Thread.sleep(100); - m1.removeMessage(m); - Thread.sleep(100); - assert (morphium.createQueryFor(Msg.class).countAll() == 0); - } finally { - m1.terminate(); - } - - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void timeoutMessages(Morphium morphium) throws Exception { - final AtomicInteger cnt = new AtomicInteger(); - SingleCollectionMessaging m1 = createMsg(morphium, 1000, false); - try { - m1.addListenerForTopic("test", (msg, m) -> { - log.error("ERROR!"); - cnt.incrementAndGet(); - return null; - }); - m1.setUseChangeStream(false).start(); - Thread.sleep(100); - Msg m = new Msg().setMsgId(new MorphiumId()).setMsg("test").setTopic("name").setValue("a value").setTtl(-1000); - m1.sendMessage(m); - Thread.sleep(200); - assert (cnt.get() == 0); - } finally { - m1.terminate(); - } - - - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void selfMessages(Morphium morphium) throws Exception { - morphium.dropCollection(Msg.class); - SingleCollectionMessaging sender = createMsg(morphium, 100, false); - sender.setUseChangeStream(false).start(); - Thread.sleep(2500); - sender.addListenerForTopic("test", ((msg, m) -> { - gotMessage = true; - log.info("Got message: " + m.getMsg() + "/" + m.getTopic()); - return null; - })); - - gotMessage = false; - gotMessage1 = false; - gotMessage2 = false; - gotMessage3 = false; - gotMessage4 = false; - - SingleCollectionMessaging m1 = createMsg(morphium, 100, false); - m1.addListenerForTopic("test", (msg, m) -> { - gotMessage1 = true; - return new Msg(m.getTopic(), "got message", "value", 5000); - }); - m1.setUseChangeStream(false).start(); - try { - sender.sendMessageToSelf(new Msg("test", "Selfmessage", "value")); - Thread.sleep(1500); - assert (gotMessage); - assert (!gotMessage1); - } finally { - m1.terminate(); - sender.terminate(); - } - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void getPendingMessagesOnStartup(Morphium morphium) throws Exception { - morphium.dropCollection(Msg.class); - Thread.sleep(1000); - SingleCollectionMessaging sender = createMsg(morphium, 100, false); - sender.setUseChangeStream(false).start(); - - gotMessage1 = false; - gotMessage2 = false; - gotMessage3 = false; - gotMessage4 = false; - - SingleCollectionMessaging m3 = createMsg(morphium, 100, false); - SingleCollectionMessaging m2 = createMsg(morphium, 100, false); - SingleCollectionMessaging m1 = createMsg(morphium, 100, false); - - try { - m3.addListenerForTopic("test", (msg, m) -> { - gotMessage3 = true; - return null; - }); - - m3.setUseChangeStream(false).start(); - - Thread.sleep(1500); - - - sender.sendMessage(new Msg("test", "testmsg", "testvalue", 120000, false)); - - Thread.sleep(1000); - assert (gotMessage3); - Thread.sleep(2000); - - - m1.addListenerForTopic("test", (msg, m) -> { - gotMessage1 = true; - return null; - }); - - m1.setUseChangeStream(false).start(); - - Thread.sleep(1500); - assert (gotMessage1); - - - m2.addListenerForTopic("test", (msg, m) -> { - gotMessage2 = true; - return null; - }); - - m2.setUseChangeStream(false).start(); - - Thread.sleep(1500); - assert (gotMessage2); - - } finally { - m1.terminate(); - m2.terminate(); - m3.terminate(); - sender.terminate(); - } - - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void waitingForMessagesIfNonMultithreadded(Morphium morphium) throws Exception { - morphium.dropCollection(Msg.class); - Thread.sleep(1000); - SingleCollectionMessaging sender = createMsg(morphium, 100, false, false, 10); - sender.setUseChangeStream(false).start(); - - list.clear(); - SingleCollectionMessaging receiver = createMsg(morphium, 100, false, false, 10); - receiver.addListenerForTopic("test", (msg, m) -> { - list.add(m); - try { - Thread.sleep(2000); - } catch (InterruptedException e) { - - } - - return null; - }); - receiver.setUseChangeStream(false).start(); - try { - Thread.sleep(500); - sender.sendMessage(new Msg("test", "test", "test")); - sender.sendMessage(new Msg("test", "test", "test")); - - Thread.sleep(500); - assert (list.size() == 1) : "Size wrong: " + list.size(); - Thread.sleep(2200); - assert (list.size() == 2); - } finally { - sender.terminate(); - receiver.terminate(); - } - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void waitingForMessagesIfMultithreadded(Morphium morphium) throws Exception { - morphium.dropCollection(Msg.class); - morphium.getConfig().messagingSettings().setThreadPoolMessagingCoreSize(5); - log.info("Max threadpool:" + morphium.getConfig().messagingSettings().getThreadPoolMessagingCoreSize()); - Thread.sleep(1000); - SingleCollectionMessaging sender = createMsg(morphium, 100, false, true, 10); - sender.setUseChangeStream(false).start(); - - list.clear(); - SingleCollectionMessaging receiver = createMsg(morphium, 100, false, true, 10); - receiver.addListenerForTopic("test", (msg, m) -> { - log.info("Incoming message..."); - list.add(m); - try { - Thread.sleep(2000); - } catch (InterruptedException e) { - - } - - return null; - }); - receiver.setUseChangeStream(false).start(); - try { - Thread.sleep(100); - sender.sendMessage(new Msg("test", "test", "test")); - sender.sendMessage(new Msg("test", "test", "test")); - Thread.sleep(1000); - - assert (list.size() == 2) : "Size wrong: " + list.size(); - } finally { - sender.terminate(); - receiver.terminate(); - } - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void priorityTest(Morphium morphium) throws Exception { - SingleCollectionMessaging sender = createMsg(morphium, 100, false); - sender.setUseChangeStream(false).start(); - Thread.sleep(250); - list.clear(); - //if running multithreadded, the execution order might differ a bit because of the concurrent - //execution - hence if set to multithreadded, the test will fail! - SingleCollectionMessaging receiver = createMsg(morphium, 10, false, false, 100); - try { - receiver.addListenerForTopic("test", (msg, m) -> { - log.info("Incoming message: prio " + m.getPriority() + " timestamp: " + m.getTimestamp()); - list.add(m); - return null; - }); - - for (int i = 0; i < 10; i++) { - Msg m = new Msg("test", "test", "test"); - m.setPriority((int) (1000.0 * Math.random())); - log.info("Stored prio: " + m.getPriority()); - sender.sendMessage(m); - } - - Thread.sleep(1000); - receiver.setUseChangeStream(false).start(); - - while (list.size() < 10) { - Thread.yield(); - } - - int lastValue = -888888; - - for (Msg m : list) { - log.info("prio: " + m.getPriority()); - assert (m.getPriority() >= lastValue); - lastValue = m.getPriority(); - } - - - receiver.pauseTopicProcessing("test"); - list.clear(); - for (int i = 0; i < 10; i++) { - Msg m = new Msg("test", "test", "test"); - m.setPriority((int) (10000.0 * Math.random())); - log.info("Stored prio: " + m.getPriority()); - sender.sendMessage(m); - } - - Thread.sleep(1000); - receiver.unpauseTopicProcessing("test"); - while (list.size() < 10) { - Thread.yield(); - } - - lastValue = -888888; - - for (Msg m : list) { - log.info("prio: " + m.getPriority()); - assert (m.getPriority() >= lastValue); - lastValue = m.getPriority(); - } - - } finally { - sender.terminate(); - receiver.terminate(); - } - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void markExclusiveMessageTest(Morphium morphium) throws Exception { - - SingleCollectionMessaging sender = createMsg(morphium, 100, false); - morphium.dropCollection(Msg.class, sender.getCollectionName(), null); - sender.setUseChangeStream(false).start(); - SingleCollectionMessaging receiver = createMsg(morphium, 10, false, true, 10); - receiver.setUseChangeStream(false).start(); - SingleCollectionMessaging receiver2 = createMsg(morphium, 10, false, true, 10); - receiver2.setUseChangeStream(false).start(); - - final AtomicInteger pausedReciever = new AtomicInteger(0); - - try { - Thread.sleep(100); - receiver.addListenerForTopic("test", (msg, m) -> { -// log.info("R1: Incoming message"); - assert (pausedReciever.get() != 1); - return null; - }); - - receiver2.addListenerForTopic("test", (msg, m) -> { -// log.info("R2: Incoming message"); - assert (pausedReciever.get() != 2); - return null; - }); - - - for (int i = 0; i < 200; i++) { - Msg m = new Msg("test", "test", "value", 3000000, true); - sender.sendMessage(m); - if (i == 100) { - receiver2.pauseTopicProcessing("test"); - Thread.sleep(50); - pausedReciever.set(2); - } else if (i == 120) { - receiver.pauseTopicProcessing("test"); - Thread.sleep(50); - pausedReciever.set(1); - } else if (i == 160) { - receiver.unpauseTopicProcessing("test"); - //receiver.findAndProcessPendingMessages("test"); - receiver2.unpauseTopicProcessing("test"); - //receiver2.findAndProcessPendingMessages("test"); - pausedReciever.set(0); - } - - } - - long start = System.currentTimeMillis(); - Query q = morphium.createQueryFor(Msg.class).f(Msg.Fields.topic).eq("test").f(Msg.Fields.processedBy).eq(null); - while (q.countAll() > 0) { - log.info("Count is still: " + q.countAll()); - Thread.sleep(500); - } - assert (q.countAll() == 0) : "Count is wrong: " + q.countAll(); -// - } finally { - receiver.terminate(); - receiver2.terminate(); - sender.terminate(); - } - - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void exclusivityPausedUnpausingTest(Morphium morphium) throws Exception { - SingleCollectionMessaging sender = createMsg(morphium, 1000, false); - sender.setSenderId("sender"); - morphium.dropCollection(Msg.class, sender.getCollectionName(), null); - Thread.sleep(100); - sender.setUseChangeStream(false).start(); - Morphium morphium2 = new Morphium(MorphiumConfig.fromProperties(morphium.getConfig().asProperties())); - morphium2.getConfig().messagingSettings().setThreadPoolMessagingMaxSize(10); - morphium2.getConfig().messagingSettings().setThreadPoolMessagingCoreSize(5); - morphium2.getConfig().threadPoolSettings().setThreadPoolAsyncOpMaxSize(10); - SingleCollectionMessaging receiver = createMsg(morphium2, (int) (50 + 100 * Math.random()), true, true, 15); - receiver.setSenderId("r1"); - receiver.setUseChangeStream(false).start(); - - Morphium morphium3 = new Morphium(MorphiumConfig.fromProperties(morphium.getConfig().asProperties())); - morphium3.getConfig().messagingSettings().setThreadPoolMessagingMaxSize(10); - morphium3.getConfig().messagingSettings().setThreadPoolMessagingCoreSize(5); - morphium3.getConfig().threadPoolSettings().setThreadPoolAsyncOpMaxSize(10); - SingleCollectionMessaging receiver2 = createMsg(morphium3, (int) (50 + 100 * Math.random()), false, false, 15); - receiver2.setSenderId("r2"); - receiver2.setUseChangeStream(false).start(); - - Morphium morphium4 = new Morphium(MorphiumConfig.fromProperties(morphium.getConfig().asProperties())); - morphium4.getConfig().messagingSettings().setThreadPoolMessagingMaxSize(10); - morphium4.getConfig().messagingSettings().setThreadPoolMessagingCoreSize(5); - morphium4.getConfig().threadPoolSettings().setThreadPoolAsyncOpMaxSize(10); - SingleCollectionMessaging receiver3 = createMsg(morphium4, (int) (50 + 100 * Math.random()), true, false, 15); - receiver3.setSenderId("r3"); - receiver3.setUseChangeStream(false).start(); - - Morphium morphium5 = new Morphium(MorphiumConfig.fromProperties(morphium.getConfig().asProperties())); - morphium5.getConfig().messagingSettings().setThreadPoolMessagingMaxSize(10); - morphium5.getConfig().messagingSettings().setThreadPoolMessagingCoreSize(5); - morphium5.getConfig().threadPoolSettings().setThreadPoolAsyncOpMaxSize(10); - SingleCollectionMessaging receiver4 = createMsg(morphium5, (int) (50 + 100 * Math.random()), false, true, 15); - receiver4.setSenderId("r4"); - receiver4.setUseChangeStream(false).start(); - - - final AtomicInteger received = new AtomicInteger(); - final AtomicInteger dups = new AtomicInteger(); - final Map ids = new ConcurrentHashMap<>(); - final Map recById = new ConcurrentHashMap<>(); - final Map recieveCount = new ConcurrentHashMap<>(); - Thread.sleep(100); - try { - MessageListener messageListener = (msg, m) -> { - msg.pauseTopicProcessing("m"); - try { - Thread.sleep((long) (300 * Math.random())); - } catch (InterruptedException e) { - } - //log.info("R1: Incoming message "+m.getValue()); - received.incrementAndGet(); - recieveCount.putIfAbsent(msg.getSenderId(), new AtomicInteger()); - recieveCount.get(msg.getSenderId()).incrementAndGet(); - if (ids.containsKey(m.getMsgId().toString())) { - if (m.isExclusive()) { - log.error("Duplicate recieved message " + msg.getSenderId() + " " + (System.currentTimeMillis() - ids.get(m.getMsgId().toString())) + "ms ago"); - if (recById.get(m.getMsgId().toString()).equals(msg.getSenderId())) { - log.error("--- duplicate was processed before by me!"); - } else { - log.error("--- duplicate processed by someone else"); - } - dups.incrementAndGet(); - } - } - ids.put(m.getMsgId().toString(), System.currentTimeMillis()); - recById.put(m.getMsgId().toString(), msg.getSenderId()); - msg.unpauseTopicProcessing("m"); - return null; - }; - receiver.addListenerForTopic("m", messageListener); - receiver2.addListenerForTopic("m", messageListener); - receiver3.addListenerForTopic("m", messageListener); - receiver4.addListenerForTopic("m", messageListener); - int exclusiveAmount = 50; - int broadcastAmount = 100; - for (int i = 0; i < exclusiveAmount; i++) { - int rec = received.get(); - long messageCount = receiver.getPendingMessagesCount(); - if (i % 100 == 0) log.info("Send " + i + " recieved: " + rec + " queue: " + messageCount); - Msg m = new Msg("m", "m", "v" + i, 3000000, true); - m.setExclusive(true); - sender.sendMessage(m); - } - for (int i = 0; i < broadcastAmount; i++) { - int rec = received.get(); - long messageCount = receiver.getPendingMessagesCount(); - if (i % 100 == 0) log.info("Send boadcast" + i + " recieved: " + rec + " queue: " + messageCount); - Msg m = new Msg("m", "m", "v" + i, 3000000, false); - sender.sendMessage(m); - } - - while (received.get() != exclusiveAmount + broadcastAmount * 4) { - int rec = received.get(); - long messageCount = sender.getPendingMessagesCount(); - - log.info("Send excl: " + exclusiveAmount + " brodadcast: " + broadcastAmount + " recieved: " + rec + " queue: " + messageCount + " currently processing: " + (exclusiveAmount + broadcastAmount * 4 - rec - messageCount)); - for (SingleCollectionMessaging m : Arrays.asList(receiver, receiver2, receiver3, receiver4)) { - assert (m.getRunningTasks() <= 10) : m.getSenderId() + " runs too many tasks! " + m.getRunningTasks(); - } - assert (dups.get() == 0) : "got duplicate message"; - - Thread.sleep(1000); - } - int rec = received.get(); - long messageCount = sender.getPendingMessagesCount(); - log.info("Send " + exclusiveAmount + " recieved: " + rec + " queue: " + messageCount); - assert (received.get() == exclusiveAmount + broadcastAmount * 4) : "should have received " + (exclusiveAmount + broadcastAmount * 4) + " but actually got " + received.get(); - - for (String id : recieveCount.keySet()) { - log.info("Reciever " + id + " message count: " + recieveCount.get(id).get()); - } - log.info("R1 active: " + receiver.getRunningTasks()); - log.info("R2 active: " + receiver2.getRunningTasks()); - log.info("R3 active: " + receiver3.getRunningTasks()); - log.info("R4 active: " + receiver4.getRunningTasks()); - - - logStats(morphium); - logStats(morphium2); - logStats(morphium3); - logStats(morphium4); - logStats(morphium5); - } finally { - - sender.terminate(); - receiver.terminate(); - receiver2.terminate(); - receiver3.terminate(); - receiver4.terminate(); - morphium2.close(); - morphium3.close(); - morphium4.close(); - morphium5.close(); - } - - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void exclusivityTest(Morphium morphium) throws Exception { - SingleCollectionMessaging sender = createMsg(morphium, 100, false); - sender.setSenderId("sender"); - morphium.dropCollection(Msg.class, sender.getCollectionName(), null); - Thread.sleep(100); - sender.setUseChangeStream(false).start(); - Morphium morphium2 = new Morphium(MorphiumConfig.fromProperties(morphium.getConfig().asProperties())); - morphium2.getConfig().messagingSettings().setThreadPoolMessagingMaxSize(10); - morphium2.getConfig().messagingSettings().setThreadPoolMessagingCoreSize(5); - morphium2.getConfig().threadPoolSettings().setThreadPoolAsyncOpMaxSize(10); - SingleCollectionMessaging receiver = createMsg(morphium2, 10, true, true, 15); - receiver.setSenderId("r1"); - receiver.setUseChangeStream(false).start(); - - Morphium morphium3 = new Morphium(MorphiumConfig.fromProperties(morphium.getConfig().asProperties())); - morphium3.getConfig().messagingSettings().setThreadPoolMessagingMaxSize(10); - morphium3.getConfig().messagingSettings().setThreadPoolMessagingCoreSize(5); - morphium3.getConfig().threadPoolSettings().setThreadPoolAsyncOpMaxSize(10); - SingleCollectionMessaging receiver2 = createMsg(morphium3, 10, false, false, 15); - receiver2.setSenderId("r2"); - receiver2.setUseChangeStream(false).start(); - - Morphium morphium4 = new Morphium(MorphiumConfig.fromProperties(morphium.getConfig().asProperties())); - morphium4.getConfig().messagingSettings().setThreadPoolMessagingMaxSize(10); - morphium4.getConfig().messagingSettings().setThreadPoolMessagingCoreSize(5); - morphium4.getConfig().threadPoolSettings().setThreadPoolAsyncOpMaxSize(10); - SingleCollectionMessaging receiver3 = createMsg(morphium4, 10, true, false, 15); - receiver3.setSenderId("r3"); - receiver3.setUseChangeStream(false).start(); - - Morphium morphium5 = new Morphium(MorphiumConfig.fromProperties(morphium.getConfig().asProperties())); - morphium5.getConfig().messagingSettings().setThreadPoolMessagingMaxSize(10); - morphium5.getConfig().messagingSettings().setThreadPoolMessagingCoreSize(5); - morphium5.getConfig().threadPoolSettings().setThreadPoolAsyncOpMaxSize(10); - SingleCollectionMessaging receiver4 = createMsg(morphium5, 10, false, true, 15); - receiver4.setSenderId("r4"); - receiver4.setUseChangeStream(false).start(); - final AtomicInteger received = new AtomicInteger(); - final AtomicInteger dups = new AtomicInteger(); - final Map ids = new ConcurrentHashMap<>(); - final Map recById = new ConcurrentHashMap<>(); - final Map recieveCount = new ConcurrentHashMap<>(); - Thread.sleep(100); - - try { - MessageListener messageListener = (msg, m) -> { - try { - Thread.sleep((long) (500 * Math.random())); - } catch (InterruptedException e) { - } - received.incrementAndGet(); - recieveCount.putIfAbsent(msg.getSenderId(), new AtomicInteger()); - recieveCount.get(msg.getSenderId()).incrementAndGet(); - if (ids.containsKey(m.getMsgId().toString()) && m.isExclusive()) { - log.error("Duplicate recieved message " + msg.getSenderId() + " " + (System.currentTimeMillis() - ids.get(m.getMsgId().toString())) + "ms ago"); - if (recById.get(m.getMsgId().toString()).equals(msg.getSenderId())) { - log.error("--- duplicate was processed before by me!"); - } else { - log.error("--- duplicate processed by someone else"); - } - dups.incrementAndGet(); - } - ids.put(m.getMsgId().toString(), System.currentTimeMillis()); - recById.put(m.getMsgId().toString(), msg.getSenderId()); - //msg.unpauseProcessingOfMessagesNamed("m"); - return null; - }; - receiver.addListenerForTopic("m", messageListener); - receiver2.addListenerForTopic("m", messageListener); - receiver3.addListenerForTopic("m", messageListener); - receiver4.addListenerForTopic("m", messageListener); - int amount = 200; - int broadcastAmount = 50; - for (int i = 0; i < amount; i++) { - int rec = received.get(); - long messageCount = 0; - messageCount += receiver.getPendingMessagesCount(); - if (i % 100 == 0) log.info("Send " + i + " recieved: " + rec + " queue: " + messageCount); - Msg m = new Msg("m", "m", "v" + i, 3000000, true); - m.setExclusive(true); - sender.sendMessage(m); - } - for (int i = 0; i < broadcastAmount; i++) { - int rec = received.get(); - long messageCount = receiver.getPendingMessagesCount(); - if (i % 100 == 0) log.info("Send broadcast" + i + " recieved: " + rec + " queue: " + messageCount); - Msg m = new Msg("m", "m", "v" + i, 3000000, false); - sender.sendMessage(m); - } - - while (received.get() != amount + broadcastAmount * 4) { - int rec = received.get(); - long messageCount = sender.getPendingMessagesCount(); - log.info("Send excl: " + amount + " brodadcast: " + broadcastAmount + " recieved: " + rec + " queue: " + messageCount + " currently processing: " + (amount + broadcastAmount * 4 - rec - messageCount)); - assert (dups.get() == 0) : "got duplicate message"; - for (SingleCollectionMessaging m : Arrays.asList(receiver, receiver2, receiver3, receiver4)) { - log.info(m.getSenderId() + " active Tasks: " + m.getRunningTasks()); - } - Thread.sleep(1000); - } - int rec = received.get(); - long messageCount = sender.getPendingMessagesCount(); - log.info("Send " + amount + " recieved: " + rec + " queue: " + messageCount); - assert (received.get() == amount + broadcastAmount * 4) : "should have received " + (amount + broadcastAmount * 4) + " but actually got " + received.get(); - - for (String id : recieveCount.keySet()) { - log.info("Reciever " + id + " message count: " + recieveCount.get(id).get()); - } - log.info("R1 active: " + receiver.getRunningTasks()); - log.info("R2 active: " + receiver2.getRunningTasks()); - log.info("R3 active: " + receiver3.getRunningTasks()); - log.info("R4 active: " + receiver4.getRunningTasks()); - } finally { - sender.terminate(); - receiver.terminate(); - receiver2.terminate(); - receiver3.terminate(); - receiver4.terminate(); - morphium2.close(); - morphium3.close(); - morphium4.close(); - morphium5.close(); - } - - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void exclusiveMessageStartupTests(Morphium morphium) throws Exception { - SingleCollectionMessaging sender = createMsg(morphium, 100, false); - SingleCollectionMessaging receiverNoListener = createMsg(morphium, 100, true); - try { - sender.setSenderId("sender"); - morphium.dropCollection(Msg.class, sender.getCollectionName(), null); - Thread.sleep(100); - sender.setUseChangeStream(false).start(); - - sender.sendMessage(new Msg("test", "test", "test", 30000, true)); - sender.sendMessage(new Msg("test", "test", "test", 30000, true)); - sender.sendMessage(new Msg("test", "test", "test", 30000, true)); - Thread.sleep(1000); - receiverNoListener.setSenderId("recNL"); - receiverNoListener.setUseChangeStream(false).start(); - - assert (morphium.createQueryFor(Msg.class, sender.getCollectionName()).countAll() == 3); - } finally { - sender.terminate(); - receiverNoListener.terminate(); - } - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void exclusiveTest(Morphium morphium) throws Exception { - morphium.dropCollection(Msg.class); - SingleCollectionMessaging sender; - List recs; - - sender = createMsg(morphium, 1000, false); - sender.setSenderId("sender"); - sender.setUseChangeStream(false).start(); - final AtomicInteger counts = new AtomicInteger(); - recs = new ArrayList<>(); - for (int i = 0; i < 10; i++) { - SingleCollectionMessaging r = createMsg(morphium, 100, false); - r.setSenderId("r" + i); - recs.add(r); - r.setUseChangeStream(false).start(); - - r.addListenerForTopic("test", (m, msg) -> { - counts.incrementAndGet(); - return null; - }); - } - try { - - for (int i = 0; i < 50; i++) { - if (i % 10 == 0) log.info("Msg sent"); - sender.sendMessage(new Msg("name", "msg", "value", 20000000, true)); - } - while (counts.get() < 50) { - log.info("Still waiting for incoming messages: " + counts.get()); - Thread.sleep(1000); - } - Thread.sleep(2000); - assert (counts.get() == 50) : "Did get too many? " + counts.get(); - - - counts.set(0); - for (int i = 0; i < 10; i++) { - log.info("Msg sent"); - sender.sendMessage(new Msg("test", "msg", "value", 20000000, false)); - } - while (counts.get() < 10 * recs.size()) { - log.info("Still waiting for incoming messages: " + counts.get()); - Thread.sleep(1000); - } - Thread.sleep(2000); - assert (counts.get() == 10 * recs.size()) : "Did get too many? " + counts.get(); - - } finally { - sender.terminate(); - for (SingleCollectionMessaging r : recs) r.terminate(); - - - } - - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void severalRecipientsTest(Morphium morphium) throws Exception { - SingleCollectionMessaging sender = createMsg(morphium, 100, false); - sender.setSenderId("sender"); - sender.setUseChangeStream(false).start(); - - List receivers = new ArrayList<>(); - final List receivedBy = new Vector<>(); - - for (int i = 0; i < 10; i++) { - SingleCollectionMessaging receiver1 = createMsg(morphium, 100, false); - receiver1.setSenderId("rec" + i); - receiver1.setUseChangeStream(false).start(); - receivers.add(receiver1); - receiver1.addListenerForTopic("test", new MessageListener() { - @Override - public Msg onMessage(MorphiumMessaging msg, Msg m) { - if (receivedBy.contains(msg.getSenderId())) { - log.error("Receiving msg twice: " + m.getMsgId()); - } - receivedBy.add(msg.getSenderId()); - return null; - } - }); - } - - try { - Msg m = new Msg("test", "msg", "value"); - m.addRecipient("rec1"); - m.addRecipient("rec2"); - m.addRecipient("rec5"); - - sender.sendMessage(m); - Thread.sleep(1000); - - assert (receivedBy.size() == m.getTo().size()); - for (String r : m.getTo()) { - assert (receivedBy.contains(r)); - } - - - receivedBy.clear(); - - m = new Msg("test", "msg", "value"); - m.addRecipient("rec1"); - m.addRecipient("rec2"); - m.addRecipient("rec5"); - m.setExclusive(true); - - sender.sendMessage(m); - Thread.sleep(1000); - assert (receivedBy.size() == 1); - assert (m.getTo().contains(receivedBy.get(0))); - } finally { - for (SingleCollectionMessaging ms : receivers) { - ms.terminate(); - } - } - - } - - private SingleCollectionMessaging createMsg(Morphium m, int pause, boolean processMultiple) throws Exception { - var settings = new MessagingSettings(); - settings.setMessagingPollPause(pause); - settings.setMessagingMultithreadded(false); - if (!processMultiple) settings.setMessagingWindowSize(1); - return (SingleCollectionMessaging) m.createMessaging(settings); - } - - private SingleCollectionMessaging createMsg(Morphium m, String queueName, int pause, boolean processMultiple) throws Exception { - var settings = new MessagingSettings(); - settings.setMessageQueueName(queueName); - settings.setMessagingPollPause(pause); - settings.setMessagingMultithreadded(false); - if (!processMultiple) settings.setMessagingWindowSize(1); - return (SingleCollectionMessaging) m.createMessaging(settings); - } - - private SingleCollectionMessaging createMsg(Morphium m, int pause, boolean processMultiple, boolean multithreadded, int windowSize) throws Exception { - var settings = new MessagingSettings(); - settings.setMessagingPollPause(pause); - settings.setMessagingMultithreadded(multithreadded); - settings.setMessagingWindowSize(!processMultiple ? 1 : windowSize); - return (SingleCollectionMessaging) m.createMessaging(settings); - } - -} diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/ncmessaging/PausingUnpausingNCTests.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/ncmessaging/PausingUnpausingNCTests.java deleted file mode 100644 index 351b9f29c..000000000 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/ncmessaging/PausingUnpausingNCTests.java +++ /dev/null @@ -1,447 +0,0 @@ -package de.caluga.test.mongo.suite.ncmessaging; -import de.caluga.test.mongo.suite.base.MultiDriverTestBase; - -import de.caluga.morphium.driver.MorphiumId; -import de.caluga.morphium.messaging.MorphiumMessaging; -import de.caluga.morphium.messaging.Msg; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; -import de.caluga.morphium.Morphium; - -@Disabled -@Tag("messaging") -public class PausingUnpausingNCTests extends MultiDriverTestBase { - private final List list = new ArrayList<>(); - private final AtomicInteger queueCount = new AtomicInteger(1000); - public boolean gotMessage = false; - public boolean gotMessage1 = false; - public boolean gotMessage2 = false; - public boolean gotMessage3 = false; - public boolean gotMessage4 = false; - public boolean error = false; - public MorphiumId lastMsgId; - public AtomicInteger procCounter = new AtomicInteger(0); - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void pauseUnpauseProcessingTest(Morphium morphium) throws Exception { - morphium.dropCollection(Msg.class); - Thread.sleep(1000); - MorphiumMessaging sender = morphium.createMessaging(); - sender.setUseChangeStream(false).start(); - Thread.sleep(2500); - gotMessage1 = false; - gotMessage2 = false; - gotMessage3 = false; - gotMessage4 = false; - - MorphiumMessaging m1 = morphium.createMessaging(); - m1.addListenerForTopic("test", (msg, m) -> { - gotMessage1 = true; - return new Msg(m.getTopic(), "got message", "value", 5000); - }); - - m1.setUseChangeStream(false).start(); - - m1.pauseTopicProcessing("tst1"); - - sender.sendMessage(new Msg("test", "a message", "the value")); - Thread.sleep(1200); - assert (gotMessage1); - - gotMessage1 = false; - - sender.sendMessage(new Msg("test", "a message", "the value")); - Thread.sleep(1200); - assert (!gotMessage1); - - Long l = m1.unpauseTopicProcessing("tst1"); - log.info("Processing was paused for ms " + l); - //m1.findAndProcessPendingMessages("tst1"); - Thread.sleep(300); - - assert (gotMessage1); - gotMessage1 = false; - Thread.sleep(200); - assert (!gotMessage1); - - gotMessage1 = false; - sender.sendMessage(new Msg("test", "a message", "the value")); - Thread.sleep(1200); - assert (gotMessage1); - - - m1.terminate(); - sender.terminate(); - - } - - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void unpausingTest(Morphium morphium) throws Exception { - morphium.dropCollection(Msg.class, "msg", null); - Thread.sleep(100); - list.clear(); - final AtomicInteger cnt = new AtomicInteger(0); - MorphiumMessaging sender = morphium.createMessaging(); - sender.setUseChangeStream(false).start(); - - MorphiumMessaging receiver = morphium.createMessaging(); - receiver.setUseChangeStream(false).start(); - - Thread.sleep(1000); - receiver.addListenerForTopic("pause", (msg, m) -> { - msg.pauseTopicProcessing("pause"); - log.info("Processing pause message"); - try { - Thread.sleep(2000); - } catch (InterruptedException e) { - } - cnt.incrementAndGet(); - msg.unpauseTopicProcessing("pause"); - - return null; - }); - - receiver.addListenerForTopic("now", (msg, m) -> { - msg.pauseTopicProcessing("now"); - list.add(m); - //log.info("Incoming msg..."+m.getMsgId()); - msg.unpauseTopicProcessing("now"); - return null; - }); - - sender.sendMessage(new Msg("now", "now", "now")); - Thread.sleep(500); - assert (list.size() == 1); - - sender.sendMessage(new Msg("pause", "pause", "pause")); - sender.sendMessage(new Msg("now", "now", "now")); - Thread.sleep(500); - assert (list.size() == 2); - - sender.sendMessage(new Msg("pause", "pause", "pause")); - sender.sendMessage(new Msg("pause", "pause", "pause")); - sender.sendMessage(new Msg("pause", "pause", "pause")); - assert (cnt.get() == 0) : "Count wrong " + cnt.get(); - Thread.sleep(2000); - assert (cnt.get() == 1); - //1st message processed - Thread.sleep(2000); - //Message after unpausing: - assert (cnt.get() == 2) : "Count wrong: " + cnt.get(); - sender.sendMessage(new Msg("now", "now", "now")); - Thread.sleep(200); - assert (list.size() == 3); - Thread.sleep(2000); - //Message after unpausing: - assert (cnt.get() == 3) : "Count wrong: " + cnt.get(); - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void testPausingUnpausingInListenerMultithreadded(Morphium morphium) throws Exception { - testPausingUnpausingInListener(morphium, true); - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void testPausingUnpausingInListenerSinglethreadded(Morphium morphium) throws Exception { - testPausingUnpausingInListener(morphium, false); - } - - private void testPausingUnpausingInListener(Morphium morphium, boolean multithreadded) throws Exception { - morphium.dropCollection(Msg.class); - Thread.sleep(1000); - MorphiumMessaging sender = morphium.createMessaging(); - sender.setUseChangeStream(false).start(); - Thread.sleep(2500); - log.info("Sender ID: " + sender.getSenderId()); - - gotMessage1 = false; - gotMessage2 = false; - - MorphiumMessaging m1 = morphium.createMessaging(); - m1.addListenerForTopic("test", (msg, m) -> { - msg.pauseTopicProcessing("test"); - try { - log.info("Incoming message " + m.getMsgId() + "/" + m.getMsg() + " from " + m.getSender() + " my id: " + msg.getSenderId()); - Thread.sleep(1000); - if (m.getMsg().equals("test1")) { - gotMessage1 = true; - } - if (m.getMsg().equals("test2")) { - gotMessage2 = true; - } - } catch (InterruptedException e) { - } - msg.unpauseTopicProcessing("test"); - return null; - }); - m1.setUseChangeStream(false).start(); - log.info("receiver id: " + m1.getSenderId()); - - log.info("Testing with non-exclusive messages"); - Msg m = new Msg("test", "test1", "test", 3000000); - m.setExclusive(false); - sender.sendMessage(m); - - m = new Msg("test", "test2", "test", 3000000); - m.setExclusive(false); - sender.sendMessage(m); - - Thread.sleep(200); - assert (!gotMessage1); - assert (!gotMessage2); - - Thread.sleep(5200); - assert (gotMessage1); - assert (gotMessage2); - - log.info("... done!"); - log.info("Testing with exclusive messages..."); - - - gotMessage1 = gotMessage2 = false; - - m = new Msg("test", "test1", "test", 3000000); - m.setExclusive(true); - sender.sendMessage(m); - - m = new Msg("test", "test2", "test", 3000000); - m.setExclusive(true); - sender.sendMessage(m); - Thread.sleep(200); - assert (!gotMessage1); - assert (!gotMessage2); - - Thread.sleep(5000); - assert (gotMessage1); - assert (gotMessage2); - - sender.terminate(); - m1.terminate(); - - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void exclusiveMessageTest(Morphium morphium) throws Exception { - MorphiumMessaging sender = morphium.createMessaging(); - MorphiumMessaging receiver = morphium.createMessaging(); - sender.setUseChangeStream(false).start(); - receiver.setUseChangeStream(false).start(); - Thread.sleep(1000); - receiver.addListenerForTopic("exclusive_test", (msg, m) -> { - log.info("Incoming message!"); - return null; - } - ); - Msg ex = new Msg("exclusive_test", "a message", "A value"); - ex.setExclusive(true); - sender.sendMessage(ex); - log.info("Sent!"); - Thread.sleep(1000); - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void testPausingUnpausingInListenerExclusiveMultithreadded(Morphium morphium) throws Exception { - testPausingUnpausingInListenerExclusive(morphium, true); - } - - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void testPausingUnpausingInListenerExclusiveSinglethreadded(Morphium morphium) throws Exception { - testPausingUnpausingInListenerExclusive(morphium, false); - } - - - private void testPausingUnpausingInListenerExclusive(Morphium morphium, boolean multithreadded) throws Exception { - MorphiumMessaging sender = null; - MorphiumMessaging m1 = null; - try { - morphium.dropCollection(Msg.class); - Thread.sleep(1000); - sender = morphium.createMessaging(); - sender.setSenderId("Sender"); - // sender.setUseChangeStream(false).start(); - log.info("Sender ID: " + sender.getSenderId()); - - gotMessage1 = false; - gotMessage2 = false; - boolean[] fail = {false}; - m1 = morphium.createMessaging(); - m1.setSenderId("m1"); - m1.addListenerForTopic("test", (msg, m) -> { - msg.pauseTopicProcessing("test"); - - try { - assert (m.isExclusive()); - // assert (m.getReceivedBy().contains(msg.getSenderId())); - log.info("Incoming message " + m.getMsgId() + "/" + m.getMsg() + " from " + m.getSender() + " my id: " + msg.getSenderId()); - Thread.sleep(500); - if (m.getMsg().equals("test1")) { - if (gotMessage1) fail[0] = true; - assert (!gotMessage1); - gotMessage1 = true; - } - if (m.getMsg().equals("test2")) { - if (gotMessage2) fail[0] = true; - assert (!gotMessage2); - - gotMessage2 = true; - } - } catch (InterruptedException e) { - } - msg.unpauseTopicProcessing("test"); - return null; - }); - m1.setUseChangeStream(false).start(); - Thread.sleep(1000); - log.info("receiver id: " + m1.getSenderId()); - - - log.info("Testing with exclusive messages..."); - - - gotMessage1 = gotMessage2 = false; - assert (!fail[0]); - Msg m = new Msg("test", "test1", "test", 3000000); - m.setExclusive(true); - sender.sendMessage(m); - assert (!fail[0]); - - m = new Msg("test", "test2", "test", 3000000); - m.setExclusive(true); - sender.sendMessage(m); - Thread.sleep(500); - assert (!gotMessage1); - assert (!gotMessage2); - assert (!fail[0]); - - Thread.sleep(6500); - assert (gotMessage1); - assert (gotMessage2); - assert (!fail[0]); - } finally { - sender.terminate(); - m1.terminate(); - - } - - - } - - -// @Test -// public void massiveAnswerBigQueueTests() throws Exception { -// morphium.dropCollection(Msg.class); -// Messaging sender = new Messaging(morphium, 100, false, true, 5); -// Messaging receiver1 = new Messaging(morphium, 100, false, true, 5); -// Messaging receiver2 = new Messaging(morphium, 100, false, true, 5); -// Messaging receiver3 = new Messaging(morphium, 100, false, true, 5); -// Messaging receiver4 = new Messaging(morphium, 100, false, true, 5); -// sender.setUseChangeStream(false).start(); -// receiver1.setUseChangeStream(false).start(); -// receiver2.setUseChangeStream(false).start(); -// receiver3.setUseChangeStream(false).start(); -// receiver4.setUseChangeStream(false).start(); -// Thread.sleep(1000); -// final AtomicInteger sent = new AtomicInteger(0); -// final AtomicInteger answered = new AtomicInteger(0); -// MessageListener messageListener = (msg, m) -> { -// msg.pauseProcessingOfMessagesNamed(m.getName()); -//// log.info("Incoming request! " + m.getMsgId()); -// Thread.sleep(200 - (int) (100.0 * Math.random())); -// Msg answer = new Msg("answer", "answer", "answer", 240 * 1000); -// answer.setMapValue(m.getMapValue()); -// msg.unpauseProcessingOfMessagesNamed(m.getName()); -// return answer; -// }; -// receiver1.addListenerForMessageNamed("answer_me", messageListener); -// receiver2.addListenerForMessageNamed("answer_me", messageListener); -// receiver3.addListenerForMessageNamed("answer_me", messageListener); -// receiver4.addListenerForMessageNamed("answer_me", messageListener); -// -// sender.addListenerForMessageNamed("answer", (msg, m) -> { -//// log.info("Anwer came in: " + m.getValue()); -// answered.incrementAndGet(); -// return null; -// }); -// Runtime runtime = Runtime.getRuntime(); -// long startFree = runtime.freeMemory(); -// long startTotal = runtime.totalMemory(); -// long startMax = runtime.maxMemory(); -// int noMsg = 300; -// StringBuilder bld = new StringBuilder(); -// for (int i = 0; i < noMsg; i++) { -// bld.setLength(0); -// sent.incrementAndGet(); -// Msg m = new Msg("answer_me", "answer_me_" + i, "answer_me_" + i, 180 * 1000); -// for (int b = 0; b < 20240; b++) { -// bld.append("- ultra long text -"); -// } -// -// m.setMapValue(UtilsMap.of("bigValue", bld.toString())); -// m.setExclusive(true); -// sender.sendMessage(m); -// } -// -// long start = System.currentTimeMillis(); -// while (sent.get() > answered.get()) { -// log.info("Got: " + answered.get() + " of " + sent.get()); -// log.info("=====> Time passed: " + ((System.currentTimeMillis() - start) / 1000 / 60) + " mins"); -// logmem(startFree, startTotal, startMax); -// Thread.sleep(5000); -// } -// log.info("Got all answers... after " + (System.currentTimeMillis() - start) + "ms"); -// -// while (System.currentTimeMillis() - start < 4 * 60 * 1000) { -// log.info("=====> Time passed: " + ((System.currentTimeMillis() - start) / 1000 / 60) + " mins"); -// logmem(startFree, startTotal, startMax); -// -// Thread.sleep(5000); -// -// } -// long diff = logmem(startFree, startTotal, startMax); -// assert (diff < 10); -// } - - private long logmem(long startFree, long startTotal, long startMax) { - System.gc(); - log.info("==== Memory consumption: ======================="); - Runtime runtime = Runtime.getRuntime(); - long free = runtime.freeMemory(); - long total = runtime.totalMemory(); - long max = runtime.maxMemory(); -// -// log.info("Free Memory : "+(free/1024/1024)+"mb"); -// log.info("Total Memory : "+(total/1024/1024)+"mb"); -// log.info("Max Memory : "+(max/1024/1024)+"mb"); -// log.info("diff Free Memory : "+((free-startFree)/1024/1024)+"mb"); - long startUsed = (startTotal - startFree) / 1024 / 1024; - long used = (total - free) / 1024 / 1024; - log.info("used Memory : " + ((total - free) / 1024 / 1024) + "mb ~ " + ((double) (total - free) / (double) total * 100.0) + "%"); - log.info("Start used Memory : " + startUsed + "mb ~ " + ((double) (startUsed) / (double) (startTotal / 1024 / 1024) * 100.0) + "%"); - log.info("Diff used Mem : " + (used - startUsed) + "mb"); -// log.info("start Total Memory : "+(startTotal/1024/1024)+"mb"); -// log.info("start Max Memory : "+(startMax/1024/1024)+"mb"); - log.info("================================================"); - return used - startUsed; - } - - -} diff --git a/morphium-core/src/test/java/de/caluga/test/mongo/suite/ncmessaging/SpeedNCTests.java b/morphium-core/src/test/java/de/caluga/test/mongo/suite/ncmessaging/SpeedNCTests.java deleted file mode 100644 index 4ad5dc201..000000000 --- a/morphium-core/src/test/java/de/caluga/test/mongo/suite/ncmessaging/SpeedNCTests.java +++ /dev/null @@ -1,156 +0,0 @@ -package de.caluga.test.mongo.suite.ncmessaging; -import de.caluga.test.mongo.suite.base.MultiDriverTestBase; - -import de.caluga.morphium.messaging.MessageListener; -import de.caluga.morphium.messaging.MorphiumMessaging; -import de.caluga.morphium.messaging.Msg; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; - -import java.util.concurrent.atomic.AtomicInteger; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.MethodSource; -import de.caluga.morphium.Morphium; - -@Disabled -@Tag("messaging") -public class SpeedNCTests extends MultiDriverTestBase { - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void writeSpeed(Morphium morphium) throws Exception { - morphium.clearCollection(Msg.class); - MorphiumMessaging msg = morphium.createMessaging(); - msg.setPause(100).setMultithreadded(true).setWindowSize(1); - msg.setUseChangeStream(false).start(); - - - final long dur = 1000; - - final long start = System.currentTimeMillis(); - - for (int i = 0; i < 25; i++) { - new Thread() { - public void run() { - Msg m = new Msg("test", "test", "testval", 30000); - while (System.currentTimeMillis() < start + dur) { - msg.sendMessage(m); - m.setMsgId(null); - } - } - } .start(); - } - while (System.currentTimeMillis() < start + dur) { - Thread.sleep(10); - } - long cnt = morphium.createQueryFor(Msg.class).countAll(); - log.info("stored msg: " + cnt + " in " + dur + "ms"); - msg.terminate(); - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void writeRecSpeed(Morphium morphium) throws Exception { - morphium.clearCollection(Msg.class); -// morphium.getConfig().setThreadPoolAsyncOpCoreSize(1000); - MorphiumMessaging sender = morphium.createMessaging(); - sender.setPause(100).setMultithreadded(true).setWindowSize(1); - sender.setUseChangeStream(false).start(); - MorphiumMessaging receiver = morphium.createMessaging(); - receiver.setPause(100).setMultithreadded(true).setWindowSize(100); - receiver.setUseChangeStream(false).start(); - final AtomicInteger recCount = new AtomicInteger(); - - receiver.addListenerForTopic("test", new MessageListener() { - @Override - public Msg onMessage(MorphiumMessaging msg, Msg m) { - recCount.incrementAndGet(); - return null; - } - }); - - final long dur = 1000; - - final long start = System.currentTimeMillis(); - - for (int i = 0; i < 15; i++) { - new Thread() { - public void run() { - Msg m = new Msg("test", "test", "testval", 30000); - while (System.currentTimeMillis() < start + dur) { - sender.sendMessage(m); - m.setMsgId(null); - } - } - } .start(); - } - - while (System.currentTimeMillis() < start + dur) { - Thread.sleep(10); - } - long cnt = morphium.createQueryFor(Msg.class).countAll(); - log.info("Messages sent: " + cnt + " received: " + recCount.get() + " in " + dur + "ms"); - sender.terminate(); - receiver.terminate(); - } - - @ParameterizedTest - @MethodSource("getMorphiumInstancesNoSingle") - public void writeExclusiveRec(Morphium morphium) throws Exception { -// morphium.getConfig().setThreadPoolAsyncOpCoreSize(1000); - morphium.clearCollection(Msg.class); - MorphiumMessaging sender = morphium.createMessaging(); - sender.setPause(100).setMultithreadded(true).setWindowSize(1); - sender.setUseChangeStream(false).start(); - MorphiumMessaging receiver = morphium.createMessaging(); - receiver.setPause(100).setMultithreadded(true).setWindowSize(100); - receiver.setUseChangeStream(false).start(); - MorphiumMessaging receiver2 = morphium.createMessaging(); - receiver2.setPause(100).setMultithreadded(true).setWindowSize(100); - receiver2.setUseChangeStream(false).start(); - final AtomicInteger recCount = new AtomicInteger(); - - receiver.addListenerForTopic("test", new MessageListener() { - @Override - public Msg onMessage(MorphiumMessaging msg, Msg m) { - recCount.incrementAndGet(); - return null; - } - }); - receiver2.addListenerForTopic("test", new MessageListener() { - @Override - public Msg onMessage(MorphiumMessaging msg, Msg m) { - recCount.incrementAndGet(); - return null; - } - }); - - final long dur = 1000; - - final long start = System.currentTimeMillis(); - - for (int i = 0; i < 15; i++) { - new Thread() { - public void run() { - Msg m = new Msg("test", "test", "testval", 30000); - m.setExclusive(true); - while (System.currentTimeMillis() < start + dur) { - sender.sendMessage(m); - m.setMsgId(null); - } - } - } .start(); - } - - while (System.currentTimeMillis() < start + dur) { - Thread.sleep(10); - } - long cnt = morphium.createQueryFor(Msg.class).countAll(); - log.info("Messages sent: " + cnt + " received: " + recCount.get() + " in " + dur + "ms"); - sender.terminate(); - receiver.terminate(); - } - - -} diff --git a/morphium-core/src/test/java/de/caluga/test/morphium/driver/BsonTest.java b/morphium-core/src/test/java/de/caluga/test/morphium/driver/BsonTest.java index eae2b4f8b..6c2fdfe15 100644 --- a/morphium-core/src/test/java/de/caluga/test/morphium/driver/BsonTest.java +++ b/morphium-core/src/test/java/de/caluga/test/morphium/driver/BsonTest.java @@ -52,10 +52,40 @@ public void encodeDecodeTest() throws Exception { BsonDecoder dec = new BsonDecoder(); Map aDoc = dec.decodeDocument(bytes); - assert (aDoc.equals(doc)); + assertTrue((aDoc.equals(doc))); } + @Test + public void decimal128RoundtripTest() throws Exception { + // Decimal128 (BSON type 0x13) is what mongodump/mongorestore ship for NumberDecimal + // values; the decoder used to throw "unknown data type: 19" on it. + Doc doc = Doc.of(); + doc.put("saldo", new java.math.BigDecimal("1234.56")); + doc.put("neg", new java.math.BigDecimal("-0.000001")); + doc.put("big", new java.math.BigDecimal("9.999999999999999999999999999999999E+6144")); + + byte[] bytes = BsonEncoder.encodeDocument(doc); + Map decoded = new BsonDecoder().decodeDocument(bytes); + + assertEquals(0, ((java.math.BigDecimal) decoded.get("saldo")).compareTo(new java.math.BigDecimal("1234.56"))); + assertEquals(0, ((java.math.BigDecimal) decoded.get("neg")).compareTo(new java.math.BigDecimal("-0.000001"))); + assertEquals(0, ((java.math.BigDecimal) decoded.get("big")) + .compareTo(new java.math.BigDecimal("9.999999999999999999999999999999999E+6144"))); + } + + @Test + public void decimal128NaNSurvivesAsDecimal128Test() throws Exception { + // NaN/Infinity have no BigDecimal representation - they round-trip as Decimal128 + Doc doc = Doc.of(); + doc.put("nan", org.bson.types.Decimal128.NaN); + + byte[] bytes = BsonEncoder.encodeDocument(doc); + Map decoded = new BsonDecoder().decodeDocument(bytes); + + assertEquals(org.bson.types.Decimal128.NaN, decoded.get("nan")); + } + @Test public void mongoIdTest() throws Exception { List lst = new ArrayList<>(); @@ -65,7 +95,7 @@ public void mongoIdTest() throws Exception { log.info("Created " + i); } MorphiumId id = new MorphiumId(); - assert (!lst.contains(id)); + assertTrue((!lst.contains(id))); lst.add(id); } log.info("done"); diff --git a/morphium-core/src/test/java/de/caluga/test/morphium/driver/CollationStrengthTest.java b/morphium-core/src/test/java/de/caluga/test/morphium/driver/CollationStrengthTest.java new file mode 100644 index 000000000..e37224b23 --- /dev/null +++ b/morphium-core/src/test/java/de/caluga/test/morphium/driver/CollationStrengthTest.java @@ -0,0 +1,73 @@ +package de.caluga.test.morphium.driver; + +import de.caluga.morphium.driver.Doc; +import de.caluga.morphium.driver.commands.FindCommand; +import de.caluga.morphium.driver.commands.InsertMongoCommand; +import de.caluga.morphium.driver.inmem.InMemoryDriver; +import de.caluga.morphium.driver.inmem.QueryHelper; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.text.Collator; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * MongoDB collation strength is 1-5 (primary..identical), java.text.Collator strength is + * 0-3 (PRIMARY..IDENTICAL). Passing the mongo value through unmapped shifts every level by + * one (mongo 1 became SECONDARY) and made strength 4/5 throw IllegalArgumentException. + */ +@Tag("inmemory") +public class CollationStrengthTest { + private final String db = "colstrength"; + private final String coll = "docs"; + + @Test + public void mongoStrengthMapsToJavaCollatorStrength() { + assertEquals(Collator.PRIMARY, QueryHelper.getCollator(Doc.of("locale", "en", "strength", 1)).getStrength()); + assertEquals(Collator.SECONDARY, QueryHelper.getCollator(Doc.of("locale", "en", "strength", 2)).getStrength()); + assertEquals(Collator.TERTIARY, QueryHelper.getCollator(Doc.of("locale", "en", "strength", 3)).getStrength()); + // java.text.Collator has no quaternary level - 4 and 5 both map to IDENTICAL, + // the closest level that is at least as strong as what mongo promises. + assertEquals(Collator.IDENTICAL, QueryHelper.getCollator(Doc.of("locale", "en", "strength", 4)).getStrength()); + assertEquals(Collator.IDENTICAL, QueryHelper.getCollator(Doc.of("locale", "en", "strength", 5)).getStrength()); + } + + @Test + public void strengthOneIgnoresDiacritics() throws Exception { + var drv = seededDriver(Doc.of("name", "résumé")); + List> res = find(drv, Doc.of("name", "resume"), Doc.of("locale", "en", "strength", 1)); + assertEquals(1, res.size(), "strength 1 (primary) must ignore diacritics: 'resume' matches 'résumé'"); + } + + @Test + public void strengthTwoIgnoresCaseButNotDiacritics() throws Exception { + var drv = seededDriver(Doc.of("name", "hello"), Doc.of("name", "héllo")); + List> res = find(drv, Doc.of("name", "HELLO"), Doc.of("locale", "en", "strength", 2)); + assertEquals(1, res.size(), "strength 2 (secondary) must ignore case but keep diacritics significant"); + } + + @Test + public void strengthFiveIsAcceptedAndCaseSensitive() throws Exception { + var drv = seededDriver(Doc.of("name", "hello")); + List> res = find(drv, Doc.of("name", "HELLO"), Doc.of("locale", "en", "strength", 5)); + assertEquals(0, res.size(), "strength 5 (identical) must be accepted and stay case sensitive"); + } + + private InMemoryDriver seededDriver(Map... docs) throws Exception { + var drv = new InMemoryDriver(); + drv.connect(); + new InsertMongoCommand(drv).setDb(db).setColl(coll).setDocuments(List.of(docs)).execute(); + return drv; + } + + private List> find(InMemoryDriver drv, Map filter, + Map collation) throws Exception { + FindCommand fnd = new FindCommand(drv).setDb(db).setColl(coll).setFilter(filter).setCollation(collation); + List> res = fnd.execute(); + fnd.releaseConnection(); + return res; + } +} diff --git a/morphium-core/src/test/java/de/caluga/test/morphium/driver/InMemInsertWriteErrorsTest.java b/morphium-core/src/test/java/de/caluga/test/morphium/driver/InMemInsertWriteErrorsTest.java new file mode 100644 index 000000000..22c7687b4 --- /dev/null +++ b/morphium-core/src/test/java/de/caluga/test/morphium/driver/InMemInsertWriteErrorsTest.java @@ -0,0 +1,47 @@ +package de.caluga.test.morphium.driver; + +import de.caluga.morphium.driver.Doc; +import de.caluga.morphium.driver.MorphiumId; +import de.caluga.morphium.driver.inmem.InMemoryDriver; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * writeErrors.index must refer to the client's original batch positions. The insert path + * removes failed documents from its working list between its error-detection loops, so a + * later loop's index silently shifted once an earlier loop had removed a document. + */ +@Tag("inmemory") +public class InMemInsertWriteErrorsTest { + private final String db = "bulkerr"; + private final String coll = "docs"; + + @Test + public void unorderedWriteErrorIndexesReferToOriginalBatchPositions() throws Exception { + var drv = new InMemoryDriver(); + drv.connect(); + MorphiumId committed = new MorphiumId(); + drv.insert(db, coll, List.of(Doc.of("_id", committed, "seed", true)), null); + + MorphiumId y = new MorphiumId(); + List> batch = new ArrayList<>(List.of( + Doc.of("_id", committed, "n", 0), // duplicate vs committed doc -> error at 0 + Doc.of("_id", y, "n", 1), + Doc.of("_id", y, "n", 2), // intra-batch duplicate -> error at 2 + Doc.of("_id", new MorphiumId(), "n", 3))); + + var writeErrors = drv.insert(db, coll, batch, null, false); + + assertEquals(2, writeErrors.size()); + assertEquals(0, ((Number) writeErrors.get(0).get("index")).intValue(), + "first error is the duplicate against the committed document, at batch index 0"); + assertEquals(2, ((Number) writeErrors.get(1).get("index")).intValue(), + "the intra-batch duplicate sits at batch index 2 - the index must not shift because index 0 was removed from the working list"); + } +} diff --git a/morphium-core/src/test/java/de/caluga/test/morphium/driver/UpdateOperatorTest.java b/morphium-core/src/test/java/de/caluga/test/morphium/driver/UpdateOperatorTest.java index a941f2b73..0e7bdfa2b 100644 --- a/morphium-core/src/test/java/de/caluga/test/morphium/driver/UpdateOperatorTest.java +++ b/morphium-core/src/test/java/de/caluga/test/morphium/driver/UpdateOperatorTest.java @@ -80,6 +80,41 @@ private Map reload(MorphiumId id) throws Exception { return res.get(0); } + @Test + public void addToSetOnMissingField_createsArray() throws Exception { + MorphiumId id = seed(Doc.of("counter", 1)); + + update(id, Doc.of("$addToSet", Doc.of("tags", "a"))); + + assertEquals(List.of("a"), reload(id).get("tags"), "$addToSet on a missing field must create the array"); + } + + @Test + public void addToSetOnExplicitNullField_failsLikeMongod() throws Exception { + // mongod distinguishes a MISSING field (array gets created) from a field explicitly + // stored as null: "Cannot apply $addToSet to non-array field. Field named 'tags' has + // non-array type null". Legacy/foreign writers produce such documents (#291). + Doc doc = Doc.of("counter", 1); + doc.put("tags", null); + MorphiumId id = seed(doc); + + assertThrows(MorphiumDriverException.class, + () -> update(id, Doc.of("$addToSet", Doc.of("tags", "a"))), + "$addToSet on an explicitly-null field must fail like mongod"); + assertNull(reload(id).get("tags"), "the failed update must not modify the field"); + } + + @Test + public void pushOnExplicitNullField_failsLikeMongod() throws Exception { + Doc doc = Doc.of("counter", 1); + doc.put("tags", null); + MorphiumId id = seed(doc); + + assertThrows(MorphiumDriverException.class, + () -> update(id, Doc.of("$push", Doc.of("tags", "a"))), + "$push on an explicitly-null field must fail like mongod"); + } + @Test public void pullWithElemMatch_removesMatchingElements() throws Exception { MorphiumId id = seed(Doc.of("results", new ArrayList<>(List.of( diff --git a/morphium-core/src/test/java/de/caluga/test/morphium/driver/WireProtocolTests.java b/morphium-core/src/test/java/de/caluga/test/morphium/driver/WireProtocolTests.java index fbc58edc3..2573f03db 100644 --- a/morphium-core/src/test/java/de/caluga/test/morphium/driver/WireProtocolTests.java +++ b/morphium-core/src/test/java/de/caluga/test/morphium/driver/WireProtocolTests.java @@ -34,7 +34,7 @@ public void testOpMsg() throws Exception { WireProtocolMessage wp = WireProtocolMessage.parseFromStream(new ByteArrayInputStream(data)); assertNotNull(wp); ; - assert(wp instanceof OpMsg); + assertTrue((wp instanceof OpMsg)); } @Test diff --git a/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/BeforeImageOnlyWhenNeededTest.java b/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/BeforeImageOnlyWhenNeededTest.java index 85d1b0df7..46f154a67 100644 --- a/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/BeforeImageOnlyWhenNeededTest.java +++ b/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/BeforeImageOnlyWhenNeededTest.java @@ -187,6 +187,103 @@ public boolean isContinued() { assertFalse(fullDoc.containsKey("tag")); } + /** + * Issue #274: the change-stream event's before-image is no longer deep-copied a second time - + * {@code updateInternal} hands its own {@code deepClone} over to the notification path, which + * only normalizes the {@code _id}. That is only sound if the handed-over map really shares no + * structure with the live document, so this pins exactly that: nested Map and List containers + * (the two shapes update operators mutate IN PLACE) captured in a first event must not change + * when a second update mutates them afterwards. The after-image is checked the same way - it + * IS a live reference at build time and must keep its unconditional deep copy. + */ + @Test + void beforeImageInEventStaysIsolatedFromLaterInPlaceMutations() throws Exception { + InMemoryDriver drv = freshDriver(); + String coll = "beforeImageIsolation"; + drv.insert(db, coll, List.of(Doc.of("_id", 1, + "nested", Doc.of("inner", Doc.of("val", "orig")), + "tags", new ArrayList<>(List.of("x", "y")))), null, true); + + MongoConnection watchConnection = drv.getPrimaryConnection(null); + List> events = new ArrayList<>(); + CountDownLatch latch = new CountDownLatch(2); + DriverTailableIterationCallback callback = new DriverTailableIterationCallback() { + @Override + public void incomingData(Map data, long dur) { + synchronized (events) { + events.add(data); + } + latch.countDown(); + } + + @Override + public boolean isContinued() { + return latch.getCount() > 0; + } + }; + + WatchCommand watch = new WatchCommand(watchConnection) + .setDb(db) + .setColl(coll) + .setFullDocument(WatchCommand.FullDocumentEnum.updateLookup) + .setFullDocumentBeforeChange(WatchCommand.FullDocumentBeforeChangeEnum.whenAvailable) + .setBatchSize(1) + .setMaxTimeMS(5000) + .setCb(callback); + + Thread watcher = Thread.ofVirtual().start(() -> { + try { + watch.watch(); + } catch (MorphiumDriverException e) { + throw new RuntimeException(e); + } finally { + watch.releaseConnection(); + } + }); + Thread.sleep(100); + + // First update: mutates the nested Map in place and appends to the existing List. + drv.update(db, coll, Doc.of("_id", 1), null, + Doc.of("$set", Doc.of("nested.inner.val", "first"), "$push", Doc.of("tags", "z")), + false, false, null, null); + // Second update: mutates the very same live containers again. If either image of the FIRST + // event still shared them, its captured values would change retroactively. + drv.update(db, coll, Doc.of("_id", 1), null, + Doc.of("$set", Doc.of("nested.inner.val", "second"), "$push", Doc.of("tags", "w")), + false, false, null, null); + + if (!latch.await(5, TimeUnit.SECONDS)) { + fail("expected two change stream events"); + } + watcher.join(); + + Map firstEvent; + synchronized (events) { + assertEquals(2, events.size()); + firstEvent = events.get(0); + } + + @SuppressWarnings("unchecked") + Map before = (Map) firstEvent.get("fullDocumentBeforeChange"); + assertNotNull(before); + @SuppressWarnings("unchecked") + Map beforeInner = (Map) ((Map) before.get("nested")).get("inner"); + assertEquals("orig", beforeInner.get("val"), + "the first event's before-image must still show the pre-update nested value"); + assertEquals(List.of("x", "y"), before.get("tags"), + "the first event's before-image must not have grown by the later $push operations"); + + @SuppressWarnings("unchecked") + Map after = (Map) firstEvent.get("fullDocument"); + assertNotNull(after); + @SuppressWarnings("unchecked") + Map afterInner = (Map) ((Map) after.get("nested")).get("inner"); + assertEquals("first", afterInner.get("val"), + "the first event's after-image must be frozen at the first update, not follow the live document"); + assertEquals(List.of("x", "y", "z"), after.get("tags"), + "the first event's after-image must not have grown by the SECOND update's $push"); + } + @Test void updateInsideTransactionFullyRevertsOnUniqueViolation() throws Exception { InMemoryDriver drv = freshDriver(); diff --git a/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/CollectionIndexStoreTest.java b/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/CollectionIndexStoreTest.java index 29ef6f003..92f09d99c 100644 --- a/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/CollectionIndexStoreTest.java +++ b/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/CollectionIndexStoreTest.java @@ -1,6 +1,7 @@ package de.caluga.test.morphium.driver.inmem; import de.caluga.morphium.driver.MorphiumDriverException; +import de.caluga.morphium.driver.MorphiumId; import de.caluga.morphium.driver.inmem.CollectionIndexStore; import de.caluga.morphium.driver.inmem.IndexDefinition; import de.caluga.morphium.driver.inmem.IndexKey; @@ -40,6 +41,20 @@ private static IndexDefinition uniqueIndex(String name, String field) { return IndexDefinition.fromIndexMap(indexMap); } + private static IndexDefinition sparseUniqueIndex(String name, String field) { + Map indexMap = new LinkedHashMap<>(); + indexMap.put(field, 1); + indexMap.put("$options", Map.of("name", name, "unique", true, "sparse", true)); + return IndexDefinition.fromIndexMap(indexMap); + } + + private static IndexDefinition partialUniqueIndex(String name, String field, Map filter) { + Map indexMap = new LinkedHashMap<>(); + indexMap.put(field, 1); + indexMap.put("$options", Map.of("name", name, "unique", true, "partialFilterExpression", filter)); + return IndexDefinition.fromIndexMap(indexMap); + } + private static IndexDefinition index(String name, String field, int direction) { Map indexMap = new LinkedHashMap<>(); indexMap.put(field, direction); @@ -91,6 +106,122 @@ void addIndexBuildsFromExistingDocsAndEqualityLookupHitsAndMisses() { assertTrue(miss.isEmpty()); } + // ---------------------------------------------------------------- sparse unique indexes + + @Test + void sparseUniqueIndexAllowsMultipleDocsWithoutTheField() { + // mongorestore of a typical schema (unique+sparse email index over docs that mostly + // lack the field) used to throw E11000 on IndexKey.MISSING here + CollectionIndexStore store = new CollectionIndexStore(); + Map d1 = doc(1, "name", "a"); + Map d2 = doc(2, "name", "b"); + + store.addIndex(sparseUniqueIndex("email_1", "email"), List.of(d1, d2)); + + store.onInsert(doc(3, "name", "c")); // still no email - allowed + store.onInsert(doc(4, "email", "x@y.z")); // first real value - allowed + assertThrows(de.caluga.morphium.driver.MorphiumDriverException.class, + () -> store.onInsert(doc(5, "email", "x@y.z")), // real duplicate - rejected + "unique must still be enforced for present values"); + } + + @Test + void sparseUniqueIndexOnUpdateSkipsMissingKeys() { + CollectionIndexStore store = new CollectionIndexStore(); + store.addIndex(sparseUniqueIndex("email_1", "email"), List.of()); + Map d1 = doc(1, "email", "a@b.c"); + Map d2 = doc(2, "email", "d@e.f"); + store.onInsert(d1); + store.onInsert(d2); + + // removing the field from both must not collide on MISSING + Map before1 = new LinkedHashMap<>(d1); + d1.remove("email"); + store.onUpdate(before1, d1); + Map before2 = new LinkedHashMap<>(d2); + d2.remove("email"); + store.onUpdate(before2, d2); + } + + @Test + void nonSparseUniqueIndexStillCollidesOnMissing() { + // mongod parity: without sparse, absent counts as null and collides + CollectionIndexStore store = new CollectionIndexStore(); + store.addIndex(uniqueIndex("email_1", "email"), List.of()); + store.onInsert(doc(1, "name", "a")); + assertThrows(de.caluga.morphium.driver.MorphiumDriverException.class, + () -> store.onInsert(doc(2, "name", "b"))); + } + + // ------------------------------------------------- partial (partialFilterExpression) indexes + + @Test + void partialUniqueIndexAllowsDocsOutsideTheFilter() { + // JEF's tasks collection: {msg_id:1}, unique, partialFilterExpression + // {msg_id:{$type:"objectId"}}. Documents whose msg_id is absent or not an ObjectId are + // not part of the index in MongoDB, so they can never collide there. + CollectionIndexStore store = new CollectionIndexStore(); + Map filter = Map.of("msg_id", Map.of("$type", "objectId")); + store.addIndex(partialUniqueIndex("uniq_msg_id_partial", "msg_id", filter), List.of()); + + store.onInsert(doc(1)); // no msg_id at all + store.onInsert(doc(2)); // second one - still outside the filter + store.onInsert(doc(3, "msg_id", "x")); // String, not an ObjectId + store.onInsert(doc(4, "msg_id", "x")); // same String - also outside the filter + + MorphiumId shared = new MorphiumId(); + store.onInsert(doc(5, "msg_id", shared)); // first ObjectId - inside the filter + assertThrows(MorphiumDriverException.class, + () -> store.onInsert(doc(6, "msg_id", shared)), + "unique must still be enforced for documents the filter covers"); + } + + @Test + void partialUniqueIndexIgnoresUncoveredDocsAlreadyInTheBucket() { + // The filter selects on a DIFFERENT field than the indexed one: an uncovered document + // sits in the same key bucket, but must not make a covered document collide. + CollectionIndexStore store = new CollectionIndexStore(); + Map filter = Map.of("active", true); + store.addIndex(partialUniqueIndex("email_1", "email", filter), List.of()); + + store.onInsert(doc(1, "email", "a@b.c", "active", false)); // not indexed by mongod + store.onInsert(doc(2, "email", "a@b.c", "active", true)); // indexed, but alone there + + assertThrows(MorphiumDriverException.class, + () -> store.onInsert(doc(3, "email", "a@b.c", "active", true)), + "two covered documents on the same key must still collide"); + } + + @Test + void partialUniqueIndexAddIndexSkipsUncoveredExistingDocs() { + // mongorestore path: the index is built over documents that already exist + CollectionIndexStore store = new CollectionIndexStore(); + Map filter = Map.of("msg_id", Map.of("$type", "objectId")); + + store.addIndex(partialUniqueIndex("uniq_msg_id_partial", "msg_id", filter), + List.of(doc(1), doc(2), doc(3, "msg_id", "x"), doc(4, "msg_id", "x"))); + } + + @Test + void partialUniqueIndexOnUpdateSkipsUncoveredDocs() { + CollectionIndexStore store = new CollectionIndexStore(); + Map filter = Map.of("msg_id", Map.of("$type", "objectId")); + store.addIndex(partialUniqueIndex("uniq_msg_id_partial", "msg_id", filter), List.of()); + + Map d1 = doc(1, "msg_id", new MorphiumId()); + Map d2 = doc(2, "msg_id", new MorphiumId()); + store.onInsert(d1); + store.onInsert(d2); + + // both drop out of the filter by losing msg_id - they must not collide on MISSING + Map before1 = new LinkedHashMap<>(d1); + d1.remove("msg_id"); + store.onUpdate(before1, d1); + Map before2 = new LinkedHashMap<>(d2); + d2.remove("msg_id"); + store.onUpdate(before2, d2); + } + @Test void addIndexThrowsOnPreexistingDuplicateAndRegistersNothing() { CollectionIndexStore store = new CollectionIndexStore(); diff --git a/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/CompiledQueryTest.java b/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/CompiledQueryTest.java index f41c5a3f4..c32da8d77 100644 --- a/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/CompiledQueryTest.java +++ b/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/CompiledQueryTest.java @@ -136,6 +136,17 @@ private static List buildMatrix() { cases.add(new Case("explicit $eq against list field, any element matches", Doc.of("a", Doc.of("$eq", 2)), Doc.of("a", List.of(1, 2, 3)), true)); cases.add(new Case("explicit $eq null==null", Doc.of("a", Doc.of("$eq", null)), Doc.of("a", null), true)); cases.add(new Case("explicit $eq value vs missing field", Doc.of("a", Doc.of("$eq", 1)), Doc.of("b", 1), false)); + // whole-array equality (mongod: {a: } matches a == OR a contains as element; + // found via mongorestore rehearsal: {processed_by: []} returned nothing) + cases.add(new Case("implicit eq empty array matches empty array field", Doc.of("a", List.of()), Doc.of("a", List.of()), true)); + cases.add(new Case("implicit eq empty array vs non-empty array field", Doc.of("a", List.of()), Doc.of("a", List.of(1)), false)); + cases.add(new Case("implicit eq empty array vs missing field", Doc.of("a", List.of()), Doc.of("b", 1), false)); + cases.add(new Case("implicit eq whole array match", Doc.of("a", List.of(1, 2)), Doc.of("a", List.of(1, 2)), true)); + cases.add(new Case("implicit eq whole array is order-sensitive", Doc.of("a", List.of(1, 2)), Doc.of("a", List.of(2, 1)), false)); + cases.add(new Case("implicit eq array as element of array field", Doc.of("a", List.of(1, 2)), Doc.of("a", List.of(List.of(1, 2), 3)), true)); + cases.add(new Case("implicit eq whole array numeric tolerance", Doc.of("a", List.of(1, 2)), Doc.of("a", List.of(1L, 2.0)), true)); + cases.add(new Case("implicit eq empty array dotted path", Doc.of("s.a", List.of()), Doc.of("s", Doc.of("a", List.of())), true)); + cases.add(new Case("implicit eq whole array dotted path", Doc.of("s.a", List.of(1, 2)), Doc.of("s", Doc.of("a", List.of(1, 2))), true)); cases.add(new Case("implicit eq MorphiumId vs string form", Doc.of("_id", new MorphiumId().toString()), Doc.of("_id", new MorphiumId()), false)); // ---------------------------------------------------------------- $ne diff --git a/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/InMemTransactionIsolationTest.java b/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/InMemTransactionIsolationTest.java index 3d2004097..bfcba1e51 100644 --- a/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/InMemTransactionIsolationTest.java +++ b/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/InMemTransactionIsolationTest.java @@ -11,6 +11,7 @@ import java.util.List; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -207,4 +208,161 @@ void transactionRemainsAbortableAfterRejectedDropDatabase() throws Exception { drv.shutdown(true); } } + + /** + * Regression test for the bug fixed alongside {@code abortTransaction}: a persistent + * {@link de.caluga.morphium.driver.inmem.CollectionIndexStore} lazily built WHILE a + * transaction is open is built from the transaction's private snapshot - i.e. from + * structurally-cloned document instances, not the live documents. If that transaction then + * aborts without invalidating the store, the store keeps registering those orphaned clones + * under their unique-index key forever (removal only matches by reference identity, so the + * clone can never be found and evicted by any later {@code onRemove}/{@code clearCollection} + * against the real live documents). Every subsequent insert of a brand-new, never-before-seen + * document under that same key is then rejected as a duplicate, even though the live + * collection is provably empty. + * + *

This is exactly the failure this test drives directly at the driver level, without + * needing to touch a real MongoDB or start a real multi-document transaction: create a + * unique index, insert a document, then force a duplicate-key insert to fail INSIDE a + * transaction (which lazily builds the persistent index store from the transaction's + * snapshot for the first time), abort, clear the collection down to zero documents, and + * finally insert a fresh document under the very same key. Before the fix, the last insert + * fails with a duplicate-key error against an empty collection; after the fix, it succeeds. + */ + @Test + void abortedTransactionDoesNotLeakStaleIndexEntriesIntoLaterInserts() throws Exception { + InMemoryDriver drv = new InMemoryDriver(); + drv.connect(); + try { + drv.createIndex("testdb", "uniqcoll", Doc.of("k", 1), Doc.of("name", "k_1", "unique", true)); + + // Insert the first, real document INSIDE a transaction that COMMITS. The commit is + // essential: commitTransaction() invalidates the persistent index store for every + // collection the transaction touched (existing, correct behaviour) - so after this, + // the store for "uniqcoll" no longer exists and the NEXT access must rebuild it. + drv.startTransaction(false); + drv.store("testdb", "uniqcoll", List.of(Doc.of("_id", 1, "k", "SB01")), null); + drv.commitTransaction(); + + // Open a SECOND transaction and attempt to insert a duplicate under the same key. + // Handling the unique-index check forces getIndexStore() to lazily rebuild the + // (invalidated) persistent store for the first time since the commit above - and it + // builds that rebuild from getCollection(), which resolves against THIS transaction's + // private snapshot while it is open (see InMemoryDriver#getDB). The snapshot's copy of + // the already-committed SB01 document is a structural CLONE + // ({@link InMemoryDriver#deepCloneDatabase}), not the same object reference stored in + // the live database. That clone gets registered into the rebuilt store's unique-index + // bucket for key "SB01". + drv.startTransaction(false); + assertThrows(MorphiumDriverException.class, + () -> drv.store("testdb", "uniqcoll", List.of(Doc.of("_id", 2, "k", "SB01")), null)); + + // Abort - the transaction's own writes are discarded, but the persistent index store + // that was just rebuilt (seeded with the CLONE of the committed SB01) is a single + // object shared across the live database and every transaction. Before the fix, + // nothing invalidates it here, so it survives the abort holding a reference to an + // object that is not the one in the live collection. + drv.abortTransaction(); + + // Clear the collection down to zero documents via delete() with an empty query - + // this is exactly the codepath Morphium.clearCollection(Class) uses in production + // (Morphium#clearCollection -> remove(createQueryFor(cls)) -> + // MorphiumWriterImpl#remove -> DeleteMongoCommand -> InMemoryDriver#delete), NOT the + // dedicated ClearCollectionCommand (which already correctly invalidates the index + // store itself and would mask this bug). The real, live SB01 document is deleted + // here via reference-identity removal from the index store. It matches and is + // removed correctly, because it was inserted through the FIRST (committed) + // transaction as itself, never as a clone. + drv.delete("testdb", "uniqcoll", Doc.of(), null, true, null, null); + assertEquals(0, drv.find("testdb", "uniqcoll", Doc.of(), null, null, 0, 0).size(), + "collection must be empty after clear"); + + // Before this fix, a lookup ON THE INDEXED FIELD (not just a full-scan query) would + // return the orphaned clone as a phantom document, since the stale index bucket + // still "finds" it even though the live collection is empty - arguably the worse + // symptom, since it surfaces through the exact codepath the index exists to serve. + assertEquals(0, drv.find("testdb", "uniqcoll", Doc.of("k", "SB01"), null, null, 0, 0).size(), + "indexed lookup on the unique-index field must not return the orphaned clone " + + "as a phantom document"); + + // A completely fresh insert under the SAME key, against a provably empty collection, + // must succeed. Before the fix this throws a duplicate-key error against the orphaned + // clone that was seeded into the store during the second (aborted) transaction's + // rebuild and never evicted, because reference-identity removal can never match a + // clone against the real object it was copied from. + assertDoesNotThrow(() -> + drv.store("testdb", "uniqcoll", List.of(Doc.of("_id", 3, "k", "SB01")), null), + "fresh insert under a key that was only ever seen (as a clone) inside an ABORTED " + + "transaction, against a now-empty collection, must not be rejected as a duplicate"); + assertEquals(1, drv.find("testdb", "uniqcoll", Doc.of(), null, null, 0, 0).size()); + } finally { + drv.shutdown(true); + } + } + + /** + * Regression test for the gap in the initial version of the {@code abortTransaction} fix + * above: it only invalidated collections in + * {@link de.caluga.morphium.driver.inmem.InMemTransactionContext#getTouchedCollections} + * (collections the transaction WROTE to). A purely READ-ONLY transaction can just as easily + * cause the persistent {@link de.caluga.morphium.driver.inmem.CollectionIndexStore} to be + * lazily rebuilt from the transaction's cloned snapshot (any {@code find()} call reaches + * {@code getIndexStore()} via {@code getDataFromIndex()}, regardless of whether an index plan + * is ultimately used), without ever calling {@code markCollectionTouched} - so the write-only + * {@code touchedCollections} set never records it, and the original fix silently skipped + * invalidating it on abort. + * + *

This test drives exactly that: commit a document so the store starts fresh-buildable, + * then open a SECOND transaction that only ever calls {@code find()} (never a write) before + * aborting for an unrelated reason, then verify a later insert under the same key - against a + * now-empty collection - is not rejected as a duplicate. + */ + @Test + void abortedReadOnlyTransactionDoesNotLeakStaleIndexEntriesEither() throws Exception { + InMemoryDriver drv = new InMemoryDriver(); + drv.connect(); + try { + drv.createIndex("testdb", "uniqcoll", Doc.of("k", 1), Doc.of("name", "k_1", "unique", true)); + + drv.startTransaction(false); + drv.store("testdb", "uniqcoll", List.of(Doc.of("_id", 1, "k", "SB01")), null); + drv.commitTransaction(); + + // Second transaction: READ ONLY. This find() call forces getIndexStore() to lazily + // rebuild the (invalidated-by-commit) persistent store for the first time since the + // commit above, from getCollection() resolving against THIS transaction's private + // snapshot - i.e. from a structurally-cloned copy of the committed SB01 document. + // markCollectionTouched is never called anywhere on this path. + drv.startTransaction(false); + assertEquals(1, drv.find("testdb", "uniqcoll", Doc.of(), null, null, 0, 0).size()); + + // Abort for an unrelated reason - no write ever happened in this transaction, so + // "uniqcoll" is absent from getTouchedCollections(), but its index store was still + // rebuilt from a clone while this transaction's snapshot was live. + drv.abortTransaction(); + + // Clear via the same production codepath as before, then insert fresh under the + // same key against a provably empty collection. + drv.delete("testdb", "uniqcoll", Doc.of(), null, true, null, null); + assertEquals(0, drv.find("testdb", "uniqcoll", Doc.of(), null, null, 0, 0).size(), + "collection must be empty after clear"); + + // Before this fix, a lookup ON THE INDEXED FIELD (not just a full-scan query) would + // return the orphaned clone as a phantom document, since the stale index bucket + // still "finds" it even though the live collection is empty - arguably the worse + // symptom, since it surfaces through the exact codepath the index exists to serve. + assertEquals(0, drv.find("testdb", "uniqcoll", Doc.of("k", "SB01"), null, null, 0, 0).size(), + "indexed lookup on the unique-index field must not return the orphaned clone " + + "as a phantom document"); + + assertDoesNotThrow(() -> + drv.store("testdb", "uniqcoll", List.of(Doc.of("_id", 3, "k", "SB01")), null), + "fresh insert under a key that was only ever seen (as a clone, via a read-only " + + "find()) inside an ABORTED transaction, against a now-empty collection, must " + + "not be rejected as a duplicate"); + assertEquals(1, drv.find("testdb", "uniqcoll", Doc.of(), null, null, 0, 0).size()); + } finally { + drv.shutdown(true); + } + } } diff --git a/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/InMemTransactionPreExistingIndexStoreStalenessTest.java b/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/InMemTransactionPreExistingIndexStoreStalenessTest.java new file mode 100644 index 000000000..768aa1579 --- /dev/null +++ b/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/InMemTransactionPreExistingIndexStoreStalenessTest.java @@ -0,0 +1,237 @@ +package de.caluga.test.morphium.driver.inmem; + +import de.caluga.morphium.IndexDescription; +import de.caluga.morphium.driver.Doc; +import de.caluga.morphium.driver.MorphiumDriverException; +import de.caluga.morphium.driver.commands.CreateIndexesCommand; +import de.caluga.morphium.driver.inmem.InMemoryDriver; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * A {@link de.caluga.morphium.driver.inmem.CollectionIndexStore} built before a transaction + * starts is not invalidated by {@code startTransaction()} (unlike a store built DURING one, + * which {@code commitTransaction()}/{@code abortTransaction()} already invalidate - see + * {@code InMemTransactionContext#getIndexStoreAccessedCollections}). Such a pre-existing store + * was built by reading through the live database and therefore holds live document instances, + * while every write inside the transaction mutates the transaction's cloned snapshot instead. + * An index-backed read (equality lookup on a secondary index) inside the transaction then keeps + * returning the pre-transaction live instance - stale relative to a full scan, which does read + * through the transaction's snapshot - and an update whose candidate came from that stale + * index-backed lookup mutates a live object the commit never merges back, so the write is lost. + * + *

Both symptoms are reproduced here. Note which one bites first without the fix: the update + * itself lands on the live document, because its candidate came from the stale index-backed + * lookup - so the transaction's own snapshot never sees the change at all, and the full-scan + * assertion is the one that fails (`expected: but was: `). The divergence + * between an index-backed lookup and a full scan is the visible surface of that; the lost write + * after commit is its consequence. + */ +@Tag("inmemory") +public class InMemTransactionPreExistingIndexStoreStalenessTest { + private static final String DB = "testdb"; + private static final String COLL = "uniqcoll"; + + @Test + void preTransactionIndexStore_doesNotSeeUpdateAppliedInsideTransaction() throws Exception { + InMemoryDriver drv = new InMemoryDriver(); + drv.connect(); + try { + new CreateIndexesCommand(drv).setDb(DB).setColl(COLL) + .addIndex(new IndexDescription().setKey(Doc.of("k", 1)).setUnique(true)) + .execute(); + drv.insert(DB, COLL, List.of(Doc.of("_id", 1, "k", "key-1", "status", "created")), + null, true); + + // Force the persistent index store to be built now, strictly BEFORE the + // transaction below starts. This equality lookup on the secondary "k" index is + // exactly the read path CollectionIndexStore.equalityLookup answers. + assertEquals("created", indexLookup(drv).get("status")); + + drv.startTransaction(false); + drv.update(DB, COLL, Doc.of("_id", 1), null, Doc.of("$set", Doc.of("status", "updated")), + false, false, null, null); + + // Read-side symptom: while the transaction is still open, an index-backed lookup + // and a full scan disagree about the very same document. + Map viaIndex = indexLookup(drv); + Map viaFullScan = fullScan(drv); + assertEquals("updated", viaFullScan.get("status"), + "full scan reads through the transaction's snapshot and must see the update"); + assertEquals("updated", viaIndex.get("status"), + "index-backed lookup must agree with the full scan inside the same " + + "transaction instead of still returning the pre-transaction live " + + "document from a store built before the transaction started"); + + drv.commitTransaction(); + + // Write-loss symptom: after commit, the update must be visible however it is read. + assertEquals("updated", fullScan(drv).get("status")); + assertEquals("updated", indexLookup(drv).get("status"), + "the update must survive commit even when read back through the " + + "index-backed path"); + } finally { + drv.shutdown(true); + } + } + + /** + * Two transactions open at the same time on different threads, each with its own cloned + * snapshot ({@code currentTransaction} is thread-local, so this is supported - see + * {@code InMemTransactionIsolationTest}). If the shared store cache were keyed by build + * ORDER rather than by transaction IDENTITY, the transaction that built its store first + * would accept the second transaction's store simply because it was built later. Its + * index-backed update would then mutate the OTHER transaction's clone: lost on its own + * commit, and corrupting the other transaction's snapshot on the way. + */ + @Test + void overlappingTransactions_doNotShareEachOthersIndexStore() throws Exception { + InMemoryDriver drv = new InMemoryDriver(); + drv.connect(); + try { + new CreateIndexesCommand(drv).setDb(DB).setColl(COLL) + .addIndex(new IndexDescription().setKey(Doc.of("k", 1)).setUnique(true)) + .execute(); + drv.insert(DB, COLL, List.of(Doc.of("_id", 1, "k", "key-1", "status", "created")), + null, true); + + // Transaction A on this thread: opens, then builds its store from its own snapshot + // via an index-backed read. + drv.startTransaction(false); + assertEquals("created", indexLookup(drv).get("status")); + + // Transaction B on another thread: opens LATER and builds a store from ITS snapshot, + // then STAYS OPEN. B's store therefore sits in the shared cache, built after A's and + // holding B's clones, at the moment A reaches for it below. B must not commit or + // abort here: either would invalidate the store (see commitTransaction/ + // abortTransaction) and A would simply rebuild, hiding the very confusion under test. + java.util.concurrent.CountDownLatch bBuiltItsStore = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch aIsDone = new java.util.concurrent.CountDownLatch(1); + Throwable[] failure = new Throwable[1]; + Thread other = new Thread(() -> { + try { + drv.startTransaction(false); + indexLookup(drv); + drv.update(DB, COLL, Doc.of("_id", 1), null, + Doc.of("$set", Doc.of("status", "from-b")), false, false, null, null); + bBuiltItsStore.countDown(); + aIsDone.await(); + drv.abortTransaction(); + } catch (Throwable t) { + failure[0] = t; + bBuiltItsStore.countDown(); + } + }); + other.start(); + bBuiltItsStore.await(); + if (failure[0] != null) { + throw new AssertionError("transaction B failed", failure[0]); + } + + // Back in A, while B is still open: an index-backed read must NOT see B's write, and + // an index-backed update must land in A's OWN snapshot. If A reused B's store, the + // candidate would be B's clone - A would read "from-b" here and its write would go + // astray. + assertEquals("created", indexLookup(drv).get("status"), + "transaction A must not see an uncommitted write from a concurrently open " + + "transaction through a shared index store"); + drv.update(DB, COLL, Doc.of("_id", 1), null, + Doc.of("$set", Doc.of("status", "from-a")), false, false, null, null); + assertEquals("from-a", indexLookup(drv).get("status"), + "transaction A must read back its own write, not another transaction's"); + assertEquals("from-a", fullScan(drv).get("status")); + + drv.commitTransaction(); + aIsDone.countDown(); + other.join(); + if (failure[0] != null) { + throw new AssertionError("transaction B failed", failure[0]); + } + + assertEquals("from-a", fullScan(drv).get("status"), + "A committed and B aborted, so A's write is the one that must survive"); + assertEquals("from-a", indexLookup(drv).get("status")); + } finally { + drv.shutdown(true); + } + } + + /** + * A reader outside any transaction must never see a still-open transaction's uncommitted + * write, not even when that transaction built the shared index store first and the reader's + * lookup is index-backed. The store built from the transaction's clones is valid only for + * that transaction; anyone else has to get a store built from the live database. + */ + @Test + void nonTransactionalReader_doesNotSeeAnOpenTransactionsUncommittedWrite() throws Exception { + InMemoryDriver drv = new InMemoryDriver(); + drv.connect(); + try { + new CreateIndexesCommand(drv).setDb(DB).setColl(COLL) + .addIndex(new IndexDescription().setKey(Doc.of("k", 1)).setUnique(true)) + .execute(); + drv.insert(DB, COLL, List.of(Doc.of("_id", 1, "k", "key-1", "status", "created")), + null, true); + + // The transaction runs on another thread and stays open, so its store - seeded with + // its own clones - is the one sitting in the shared cache while we read below. + java.util.concurrent.CountDownLatch txHasWritten = new java.util.concurrent.CountDownLatch(1); + java.util.concurrent.CountDownLatch readerIsDone = new java.util.concurrent.CountDownLatch(1); + Throwable[] failure = new Throwable[1]; + Thread tx = new Thread(() -> { + try { + drv.startTransaction(false); + indexLookup(drv); + drv.update(DB, COLL, Doc.of("_id", 1), null, + Doc.of("$set", Doc.of("status", "uncommitted")), false, false, null, null); + txHasWritten.countDown(); + readerIsDone.await(); + drv.abortTransaction(); + } catch (Throwable t) { + failure[0] = t; + txHasWritten.countDown(); + } + }); + tx.start(); + txHasWritten.await(); + if (failure[0] != null) { + throw new AssertionError("the transaction thread failed", failure[0]); + } + + // This thread has no transaction: both read paths must still show the live document. + assertEquals("created", indexLookup(drv).get("status"), + "an index-backed read outside any transaction must not observe an open " + + "transaction's uncommitted write"); + assertEquals("created", fullScan(drv).get("status")); + + readerIsDone.countDown(); + tx.join(); + if (failure[0] != null) { + throw new AssertionError("the transaction thread failed", failure[0]); + } + + // The transaction aborted, so the live document is unchanged. + assertEquals("created", indexLookup(drv).get("status")); + assertEquals("created", fullScan(drv).get("status")); + } finally { + drv.shutdown(true); + } + } + + private Map indexLookup(InMemoryDriver drv) throws MorphiumDriverException { + List> result = drv.find(DB, COLL, Doc.of("k", "key-1"), null, null, 0, 0); + assertEquals(1, result.size()); + return result.get(0); + } + + private Map fullScan(InMemoryDriver drv) throws MorphiumDriverException { + List> result = drv.find(DB, COLL, Doc.of(), null, null, 0, 0); + assertEquals(1, result.size()); + return result.get(0); + } +} diff --git a/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/UniqueIndexTest.java b/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/UniqueIndexTest.java index 8b1047c65..062806376 100644 --- a/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/UniqueIndexTest.java +++ b/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/UniqueIndexTest.java @@ -3,6 +3,7 @@ import de.caluga.morphium.IndexDescription; import de.caluga.morphium.driver.Doc; import de.caluga.morphium.driver.MorphiumDriverException; +import de.caluga.morphium.driver.MorphiumId; import de.caluga.morphium.driver.commands.CreateIndexesCommand; import de.caluga.morphium.driver.inmem.InMemoryDriver; import org.junit.jupiter.api.Tag; @@ -135,6 +136,123 @@ void orderedBatchWithInternalSecondaryUniqueDuplicate_stopsAfterFirstError() thr "ordered: the doc after the failing one must not even be attempted"); } + @Test + void partialUniqueIndex_docsOutsideTheFilterDoNotCollide() throws Exception { + // JEF's tasks index, end to end through the driver's insert path: + // {msg_id:1}, unique, partialFilterExpression {msg_id:{$type:"objectId"}} + InMemoryDriver drv = freshDriver(); + String coll = "partialUnique"; + new CreateIndexesCommand(drv).setDb(db).setColl(coll) + .addIndex(new IndexDescription().setKey(Doc.of("msg_id", 1)).setUnique(true) + .setPartialFilterExpression(Doc.of("msg_id", Doc.of("$type", "objectId")))) + .execute(); + + List> writeErrors = drv.insert(db, coll, List.of( + Doc.of("_id", 1, "name", "task without msg_id"), + Doc.of("_id", 2, "name", "another one without msg_id"), + Doc.of("_id", 3, "msg_id", "x"), + Doc.of("_id", 4, "msg_id", "x")), null, false); + + assertTrue(writeErrors.isEmpty(), "documents outside the partial filter must not collide: " + writeErrors); + assertEquals(4, drv.find(db, coll, Doc.of(), null, null, 0, 10).size()); + } + + @Test + void partialUniqueIndex_stillEnforcedForCoveredDocs() throws Exception { + InMemoryDriver drv = freshDriver(); + String coll = "partialUniqueCovered"; + new CreateIndexesCommand(drv).setDb(db).setColl(coll) + .addIndex(new IndexDescription().setKey(Doc.of("msg_id", 1)).setUnique(true) + .setPartialFilterExpression(Doc.of("msg_id", Doc.of("$type", "objectId")))) + .execute(); + + MorphiumId shared = new MorphiumId(); + List> writeErrors = drv.insert(db, coll, List.of( + Doc.of("_id", 1, "msg_id", shared), + Doc.of("_id", 2, "msg_id", shared)), null, false); + + assertEquals(1, writeErrors.size(), "the second covered document must be rejected"); + assertEquals(11000, writeErrors.get(0).get("code")); + } + + @Test + void partialUniqueFilterOnNonKeyField_uncoveredStoredDocIsNoCollisionPartner() throws Exception { + // The filter selects on a field OUTSIDE the index key, so covered and uncovered + // documents share a key bucket. A stored document outside the filter is not part of + // mongod's index and must not count as a collision partner for a covered insert. + InMemoryDriver drv = freshDriver(); + String coll = "partialNonKeyFilter"; + new CreateIndexesCommand(drv).setDb(db).setColl(coll) + .addIndex(new IndexDescription().setKey(Doc.of("email", 1)).setUnique(true) + .setPartialFilterExpression(Doc.of("active", true))) + .execute(); + + List> writeErrors = drv.insert(db, coll, + List.of(Doc.of("_id", 1, "email", "a@x.de", "active", false)), null, true); + assertTrue(writeErrors.isEmpty(), "uncovered doc must insert cleanly: " + writeErrors); + + writeErrors = drv.insert(db, coll, + List.of(Doc.of("_id", 2, "email", "a@x.de", "active", true)), null, true); + assertTrue(writeErrors.isEmpty(), + "covered doc must not collide with an UNCOVERED stored doc on the same key: " + writeErrors); + assertEquals(2, drv.find(db, coll, Doc.of(), null, null, 0, 10).size()); + + // sanity: a second COVERED doc on the same key is still a real duplicate + writeErrors = drv.insert(db, coll, + List.of(Doc.of("_id", 3, "email", "a@x.de", "active", true)), null, false); + assertEquals(1, writeErrors.size(), "two covered docs on one key must still collide"); + assertEquals(11000, writeErrors.get(0).get("code")); + } + + @Test + void updateIntoPartialFilterWithoutKeyChange_raisesDuplicate() throws Exception { + // An update that leaves the index key untouched but moves the document INTO the + // partial filter makes it a collision partner - mongod raises E11000 on this update. + InMemoryDriver drv = freshDriver(); + String coll = "partialCoverageTransition"; + new CreateIndexesCommand(drv).setDb(db).setColl(coll) + .addIndex(new IndexDescription().setKey(Doc.of("email", 1)).setUnique(true) + .setPartialFilterExpression(Doc.of("active", true))) + .execute(); + + drv.insert(db, coll, List.of( + Doc.of("_id", 1, "email", "x@x.de", "active", true), + Doc.of("_id", 2, "email", "x@x.de", "active", false)), null, true); + assertEquals(2, drv.find(db, coll, Doc.of(), null, null, 0, 10).size(), "sanity: both legal"); + + try { + drv.update(db, coll, Doc.of("_id", 2), null, Doc.of("$set", Doc.of("active", true)), + false, false, null, null); + fail("moving a doc INTO the partial filter onto an occupied key must raise E11000"); + } catch (MorphiumDriverException ex) { + assertEquals(11000, ex.getMongoCode()); + } + + List> found2 = drv.find(db, coll, Doc.of("_id", 2), null, null, 0, 10); + assertEquals(1, found2.size()); + assertEquals(false, found2.get(0).get("active"), "the rejected update must not be applied"); + } + + @Test + void updateIntoPartialFilterWithoutCollision_succeeds() throws Exception { + InMemoryDriver drv = freshDriver(); + String coll = "partialCoverageTransitionFree"; + new CreateIndexesCommand(drv).setDb(db).setColl(coll) + .addIndex(new IndexDescription().setKey(Doc.of("email", 1)).setUnique(true) + .setPartialFilterExpression(Doc.of("active", true))) + .execute(); + + drv.insert(db, coll, List.of( + Doc.of("_id", 1, "email", "a@x.de", "active", true), + Doc.of("_id", 2, "email", "b@x.de", "active", false)), null, true); + + drv.update(db, coll, Doc.of("_id", 2), null, Doc.of("$set", Doc.of("active", true)), + false, false, null, null); + + List> found2 = drv.find(db, coll, Doc.of("_id", 2), null, null, 0, 10); + assertEquals(true, found2.get(0).get("active"), "an uncontested coverage transition must be applied"); + } + @Test void replacementUpdateViolatingUniqueSecondaryIndex_errorNoChange() throws Exception { InMemoryDriver drv = freshDriver(); diff --git a/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/UserWriteEventsTest.java b/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/UserWriteEventsTest.java index f3ee232c7..c25c25363 100644 --- a/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/UserWriteEventsTest.java +++ b/morphium-core/src/test/java/de/caluga/test/morphium/driver/inmem/UserWriteEventsTest.java @@ -208,6 +208,220 @@ void updateUserChangesScramCredentials() throws Exception { assertThat(rolesAfter).as("roles preserved when not passed to updateUser").isEmpty(); } + @SuppressWarnings("unchecked") + private Map credentialsOf(String id) { + var docs = drv.findByFieldValue("admin", "system.users", "_id", id); + assertThat(docs).hasSize(1); + return (Map) docs.get(0).get("credentials"); + } + + /** + * 2026-08-06 review finding: a pwd change WITHOUT "mechanisms" used to pass null through to + * buildUserDocument, which resets to the both-mechanisms default - silently re-arming + * SCRAM-SHA-1 credentials for a user deliberately created SHA-256-only. mongod preserves + * the existing mechanism set. + */ + @Test + void updateUserPwdChangePreservesMechanismSet() throws Exception { + Map created = updateUser(Doc.of("createUser", "m1", "pwd", "pw", + "roles", List.of(), "mechanisms", List.of("SCRAM-SHA-256"), "$db", "admin")); + assertThat(created.get("ok")).as("createUser result: " + created).isEqualTo(1.0); + assertThat(credentialsOf("admin.m1").keySet()).containsExactly("SCRAM-SHA-256"); + + Map result = updateUser(Doc.of("updateUser", "m1", "pwd", "newpw", "$db", "admin")); + assertThat(result.get("ok")).as("updateUser result: " + result).isEqualTo(1.0); + + assertThat(credentialsOf("admin.m1").keySet()) + .as("a pwd-only update must keep the user's mechanism set, not reset to the default pair") + .containsExactly("SCRAM-SHA-256"); + } + + /** mongod semantics: mechanisms without pwd is a subset-only update keeping stored credentials verbatim. */ + @Test + void updateUserMechanismsOnlySubsetKeepsStoredCredentials() throws Exception { + createUser("m2", "pw"); // default: both mechanisms + Map credsBefore = credentialsOf("admin.m2"); + assertThat(credsBefore.keySet()).contains("SCRAM-SHA-1", "SCRAM-SHA-256"); + @SuppressWarnings("unchecked") + Object storedKeyBefore = ((Map) credsBefore.get("SCRAM-SHA-256")).get("storedKey"); + + Map result = updateUser(Doc.of("updateUser", "m2", + "mechanisms", List.of("SCRAM-SHA-256"), "$db", "admin")); + assertThat(result.get("ok")).as("updateUser result: " + result).isEqualTo(1.0); + + Map credsAfter = credentialsOf("admin.m2"); + assertThat(credsAfter.keySet()).containsExactly("SCRAM-SHA-256"); + @SuppressWarnings("unchecked") + Object storedKeyAfter = ((Map) credsAfter.get("SCRAM-SHA-256")).get("storedKey"); + assertThat(storedKeyAfter) + .as("without a pwd the stored credentials cannot be re-derived and must be kept verbatim") + .isEqualTo(storedKeyBefore); + } + + /** Requesting a mechanism the user has no stored credentials for must be BadValue, per mongod. */ + @Test + void updateUserMechanismsOnlyNotSubsetIsBadValue() throws Exception { + Map created = updateUser(Doc.of("createUser", "m3", "pwd", "pw", + "roles", List.of(), "mechanisms", List.of("SCRAM-SHA-256"), "$db", "admin")); + assertThat(created.get("ok")).as("createUser result: " + created).isEqualTo(1.0); + + Map result = updateUser(Doc.of("updateUser", "m3", + "mechanisms", List.of("SCRAM-SHA-1"), "$db", "admin")); + assertThat(result.get("ok")).isEqualTo(0.0); + assertThat(result.get("code")).isEqualTo(2); + assertThat(result.get("codeName")).isEqualTo("BadValue"); + assertThat(credentialsOf("admin.m3").keySet()) + .as("a rejected subset update must leave the stored credentials untouched") + .containsExactly("SCRAM-SHA-256"); + } + + // ---- dropUser (2026-08-06 follow-up: complete the user lifecycle) ---- + + /** + * mongod-compatible {@code dropUser}: removes the user document and emits a delete event on + * admin.system.users - under the same userWriteEmitLock ordering guarantee as + * createUser/updateUser, because PoppyDB secondaries replicate the drop via exactly this + * event (documentKey._id keyed delete). + */ + @Test + void dropUserRemovesUserAndEmitsDeleteEvent() throws Exception { + createUser("d1", "pw"); + ClusterWatch cw = subscribeClusterWatch(); + Map result; + try { + result = updateUser(Doc.of("dropUser", "d1", "$db", "admin")); + TestUtils.waitForConditionToBecomeTrue(5000, "no delete event for d1 arrived: " + cw.events, + () -> cw.events.stream().anyMatch(e -> "delete".equals(e.get("operationType")))); + } finally { + cw.stop(); + } + + assertThat(result.get("ok")).as("dropUser result: " + result).isEqualTo(1.0); + assertThat(drv.findByFieldValue("admin", "system.users", "_id", "admin.d1")) + .as("user document must be gone after dropUser").isEmpty(); + + Map event = cw.firstOfType("delete"); + @SuppressWarnings("unchecked") + Map ns = (Map) event.get("ns"); + assertThat(ns.get("db")).isEqualTo("admin"); + assertThat(ns.get("coll")).isEqualTo("system.users"); + @SuppressWarnings("unchecked") + Map docKey = (Map) event.get("documentKey"); + assertThat(docKey).as("delete event must carry documentKey").isNotNull(); + assertThat(docKey.get("_id")).isEqualTo("admin.d1"); + } + + @Test + void dropUserUnknownUserIsCode11() throws Exception { + Map result = updateUser(Doc.of("dropUser", "no-such-user", "$db", "admin")); + assertThat(result.get("ok")).isEqualTo(0.0); + assertThat(result.get("code")).isEqualTo(11); + assertThat(result.get("codeName")).isEqualTo("UserNotFound"); + } + + @Test + void dropUserMissingNameIsBadValue() throws Exception { + Map result = updateUser(Doc.of("dropUser", "", "$db", "admin")); + assertThat(result.get("ok")).isEqualTo(0.0); + assertThat(result.get("code")).isEqualTo(2); + assertThat(result.get("codeName")).isEqualTo("BadValue"); + } + + // ---- customData (2026-08-06 follow-up: mongod models it, we returned BadValue) ---- + + @SuppressWarnings("unchecked") + private Map userDoc(String id) { + var docs = drv.findByFieldValue("admin", "system.users", "_id", id); + assertThat(docs).hasSize(1); + return docs.get(0); + } + + @Test + void createUserStoresCustomData() throws Exception { + Map created = updateUser(Doc.of("createUser", "c1", "pwd", "pw", + "roles", List.of(), "customData", Doc.of("team", "platform"), "$db", "admin")); + assertThat(created.get("ok")).as("createUser result: " + created).isEqualTo(1.0); + + @SuppressWarnings("unchecked") + Map customData = (Map) userDoc("admin.c1").get("customData"); + assertThat(customData).as("customData must be stored on the user document").isNotNull(); + assertThat(customData.get("team")).isEqualTo("platform"); + } + + @Test + void updateUserCustomDataOnlyReplacesCustomDataAndKeepsCredentials() throws Exception { + createUser("c2", "pw"); + @SuppressWarnings("unchecked") + Object storedKeyBefore = ((Map) credentialsOf("admin.c2").get("SCRAM-SHA-256")).get("storedKey"); + + Map result = updateUser(Doc.of("updateUser", "c2", + "customData", Doc.of("dept", "42"), "$db", "admin")); + assertThat(result.get("ok")).as("customData-only updateUser must succeed (mongod allows it): " + result) + .isEqualTo(1.0); + + Map doc = userDoc("admin.c2"); + @SuppressWarnings("unchecked") + Map customData = (Map) doc.get("customData"); + assertThat(customData.get("dept")).isEqualTo("42"); + @SuppressWarnings("unchecked") + Object storedKeyAfter = ((Map) credentialsOf("admin.c2").get("SCRAM-SHA-256")).get("storedKey"); + assertThat(storedKeyAfter).as("credentials must be untouched by a customData-only update") + .isEqualTo(storedKeyBefore); + } + + @Test + void updateUserPwdChangePreservesCustomData() throws Exception { + Map created = updateUser(Doc.of("createUser", "c3", "pwd", "pw", + "roles", List.of(), "customData", Doc.of("keep", "me"), "$db", "admin")); + assertThat(created.get("ok")).as("createUser result: " + created).isEqualTo(1.0); + + Map result = updateUser(Doc.of("updateUser", "c3", "pwd", "newpw", "$db", "admin")); + assertThat(result.get("ok")).as("updateUser result: " + result).isEqualTo(1.0); + + @SuppressWarnings("unchecked") + Map customData = (Map) userDoc("admin.c3").get("customData"); + assertThat(customData).as("a pwd change without customData must preserve the stored customData") + .isNotNull(); + assertThat(customData.get("keep")).isEqualTo("me"); + } + + @Test + void malformedCustomDataIsBadValue() throws Exception { + createUser("c4", "pw"); + + Map updateResult = updateUser(Doc.of("updateUser", "c4", + "customData", "not-a-document", "$db", "admin")); + assertThat(updateResult.get("ok")).isEqualTo(0.0); + assertThat(updateResult.get("code")).isEqualTo(2); + assertThat(updateResult.get("codeName")).isEqualTo("BadValue"); + + Map createResult = updateUser(Doc.of("createUser", "c5", "pwd", "pw", + "roles", List.of(), "customData", "not-a-document", "$db", "admin")); + assertThat(createResult.get("ok")).isEqualTo(0.0); + assertThat(createResult.get("code")).isEqualTo(2); + assertThat(createResult.get("codeName")).isEqualTo("BadValue"); + } + + /** + * 2026-08-06 review finding: malformed field types used to escape as a raw + * ClassCastException out of the command handler instead of a mongod-style BadValue error. + */ + @Test + void updateUserMalformedFieldTypesAreBadValueNotClassCastException() throws Exception { + createUser("m4", "pw"); + + for (Map bad : List.of( + Doc.of("updateUser", "m4", "roles", "not-an-array", "$db", "admin"), + Doc.of("updateUser", "m4", "pwd", List.of("not-a-string"), "$db", "admin"), + Doc.of("updateUser", "m4", "mechanisms", "not-an-array", "$db", "admin"), + Doc.of("updateUser", "m4", "pwd", "npw", "mechanisms", List.of(42), "$db", "admin"))) { + Map result = updateUser(bad); + assertThat(result.get("ok")).as("command must fail cleanly: " + bad + " -> " + result).isEqualTo(0.0); + assertThat(result.get("code")).as("BadValue expected for " + bad).isEqualTo(2); + assertThat(result.get("codeName")).isEqualTo("BadValue"); + } + } + @Test void updateUserUnknownUserIsCode11() throws Exception { Map result = updateUser( diff --git a/morphium-core/src/test/java/de/caluga/test/morphium/messaging/AnsweringTests.java b/morphium-core/src/test/java/de/caluga/test/morphium/messaging/AnsweringTests.java index fc4e72e50..a4f47ae14 100644 --- a/morphium-core/src/test/java/de/caluga/test/morphium/messaging/AnsweringTests.java +++ b/morphium-core/src/test/java/de/caluga/test/morphium/messaging/AnsweringTests.java @@ -364,5 +364,38 @@ public void sendAndWaitforAnswerTimoutTest(Morphium morphium) throws Exception { } } + @ParameterizedTest + @MethodSource("getMorphiumInstancesNoSingle") + public void waitForAnswerPollingOnlyTest(Morphium morphium) throws Exception { + // Survivor of the retired ncmessaging suite (#292): request/reply round trips in pure + // polling mode - the mode every standalone-MongoDB installation runs in automatically + // (no change streams without a replica set). The mongodb_single CI phase exercises it + // implicitly for the whole messaging test set; this keeps one explicit round-trip test + // on replica-set instances too. + try (morphium) { + MorphiumMessaging m1 = morphium.createMessaging().setPause(10).setMultithreadded(false).setWindowSize(10); + MorphiumMessaging m2 = morphium.createMessaging().setPause(10).setMultithreadded(false).setWindowSize(10); + m1.setSenderId("m1"); + m2.setSenderId("m2"); + m2.addListenerForTopic("question", (msg, m) -> m.createAnswerMsg()); + m1.setUseChangeStream(false).start(); + m2.setUseChangeStream(false).start(); + assertTrue(m1.waitForReady(30, TimeUnit.SECONDS), "m1 not ready"); + assertTrue(m2.waitForReady(30, TimeUnit.SECONDS), "m2 not ready"); + + try { + for (int i = 0; i < 100; i++) { + Msg question = new Msg("question", "question" + i, "a value " + i); + question.setPriority(5); + Msg answer = m1.sendAndAwaitFirstAnswer(question, 15000); + assertNotNull(answer, "no answer for question " + i); + assertEquals(question.getMsgId(), answer.getInAnswerTo()); + } + } finally { + m1.terminate(); + m2.terminate(); + } + } + } } diff --git a/morphium-core/src/test/java/de/caluga/test/morphium/messaging/DualChannelMessagingShutdownTest.java b/morphium-core/src/test/java/de/caluga/test/morphium/messaging/DualChannelMessagingShutdownTest.java index 798d77935..ae3cc9832 100644 --- a/morphium-core/src/test/java/de/caluga/test/morphium/messaging/DualChannelMessagingShutdownTest.java +++ b/morphium-core/src/test/java/de/caluga/test/morphium/messaging/DualChannelMessagingShutdownTest.java @@ -6,6 +6,7 @@ import de.caluga.morphium.messaging.Msg; import de.caluga.morphium.messaging.SingleCollectionMessaging; import de.caluga.test.mongo.suite.base.MultiDriverTestBase; +import de.caluga.test.mongo.suite.base.TestUtils; import org.junit.jupiter.api.Tag; import org.junit.jupiter.params.ParameterizedTest; @@ -99,8 +100,18 @@ public void terminateStopsBothMonitorsAndDispatcherThreadTest(Morphium morphium) DualChannelMessaging messaging = (DualChannelMessaging) m.createMessaging(); messaging.start(); assertTrue(messaging.waitForReady(30, TimeUnit.SECONDS)); - assertTrue(messaging.changeStreamsLive() || !messaging.isUseChangeStream()); - assertTrue(messaging.dmChangeStreamLive() || !messaging.isUseChangeStream()); + // waitForReady() only promises that both monitors were STARTED - it counts down + // right after initDmChangeStream() (measured: total=6ms). Liveness is a stronger + // property: isStreamLive() stays false until the watch loop has seen its first + // server reply, so asserting it the instant waitForReady() returns is a race that + // an idle machine wins and a loaded one loses (it did, in the 5-phases-in-parallel + // CI run). Wait for the streams to actually go live instead. + if (messaging.isUseChangeStream()) { + TestUtils.waitForConditionToBecomeTrue(30000, + "main change stream did not go live", messaging::changeStreamsLive); + TestUtils.waitForConditionToBecomeTrue(30000, + "DM change stream did not go live", messaging::dmChangeStreamLive); + } String threadNamePrefix = "msg-dm-" + messaging.getSenderId(); boolean dispatcherThreadExistsBefore = Thread.getAllStackTraces().keySet().stream() diff --git a/morphium-core/src/test/java/de/caluga/test/morphium/messaging/LegacyProcessedByNullTest.java b/morphium-core/src/test/java/de/caluga/test/morphium/messaging/LegacyProcessedByNullTest.java new file mode 100644 index 000000000..93a961a16 --- /dev/null +++ b/morphium-core/src/test/java/de/caluga/test/morphium/messaging/LegacyProcessedByNullTest.java @@ -0,0 +1,100 @@ +package de.caluga.test.morphium.messaging; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import de.caluga.morphium.driver.MorphiumId; +import de.caluga.morphium.driver.inmem.InMemoryDriver; +import de.caluga.morphium.messaging.DualChannelMessaging; +import de.caluga.morphium.messaging.MorphiumMessaging; +import de.caluga.morphium.messaging.Msg; +import de.caluga.morphium.messaging.MultiCollectionMessaging; +import de.caluga.morphium.messaging.SingleCollectionMessaging; +import de.caluga.test.mongo.suite.base.TestUtils; +import de.caluga.test.mongo.suite.inmem.MorphiumInMemTestBase; + +/** + * Issue #291: a stored message document with an explicit {@code processed_by: null} must still be + * deliverable. Morphium senders initialize the field via Msg's {@code @PreStore}, but + * legacy/foreign writers (other applications mapping the same collection without the guard, raw + * driver writers, restored dumps) produce explicit nulls - and mongod rejects the + * {@code $addToSet} marking on such a field ("non-array type null"). Since 6.3.x exclusive + * messages MUST be marked before the listener runs, that turned the failed mark into a hard + * non-delivery: no listener call, no answer, sender timeout. + * + *

The InMemoryDriver mirrors mongod's null/missing distinction since #291, so this reproduces + * in-memory. + */ +@Tag("messaging") +@Tag("inmemory") +public class LegacyProcessedByNullTest extends MorphiumInMemTestBase { + + private static final String TOPIC = "legacynull"; + + static Stream implementations() { + return Stream.of(SingleCollectionMessaging.NAME, DualChannelMessaging.NAME, MultiCollectionMessaging.NAME); + } + + @ParameterizedTest + @MethodSource("implementations") + public void exclusiveMessageWithNullProcessedByIsStillDelivered(String impl) throws Exception { + MorphiumMessaging consumer; + switch (impl) { + case SingleCollectionMessaging.NAME: consumer = new SingleCollectionMessaging(); break; + case DualChannelMessaging.NAME: consumer = new DualChannelMessaging(); break; + default: consumer = new MultiCollectionMessaging(); break; + } + consumer.init(morphium); + CountDownLatch processed = new CountDownLatch(1); + consumer.addListenerForTopic(TOPIC, (m, msg) -> { + processed.countDown(); + return null; + }); + + // The legacy/foreign document: serialized WITHOUT Msg's @PreStore lifecycle (exactly how + // a foreign entity or raw-driver writer stores it), carrying an explicit null. + Msg legacy = new Msg(TOPIC, "legacy", "value", 120000, true); + legacy.setMsgId(new MorphiumId()); + legacy.setSender("legacy-foreign-sender"); + legacy.setTimestamp(System.currentTimeMillis()); + Map doc = morphium.getMapper().serialize(legacy); + doc.put("processed_by", null); + String coll = consumer.getCollectionName(TOPIC); + ((InMemoryDriver) morphium.getDriver()).insert(morphium.getDatabase(), coll, List.of(doc), null); + + try { + consumer.start(); + assertTrue(consumer.waitForReady(30, TimeUnit.SECONDS), "consumer not ready"); + + assertTrue(processed.await(15, TimeUnit.SECONDS), + "exclusive message with legacy processed_by:null must still reach the listener"); + + // the mark must have repaired the field: null -> array containing the consumer + TestUtils.waitForConditionToBecomeTrue(5000, "processed_by not repaired to an array with the consumer id", + () -> { + try { + List> found = ((InMemoryDriver) morphium.getDriver()) + .find(morphium.getDatabase(), coll, Map.of("_id", legacy.getMsgId()), null, null, 0, 0); + if (found.size() != 1) { + return false; + } + Object pb = found.get(0).get("processed_by"); + return pb instanceof List && ((List) pb).contains(consumer.getSenderId()); + } catch (Exception e) { + return false; + } + }); + } finally { + consumer.terminate(); + } + } +} diff --git a/morphium-core/src/test/java/de/caluga/test/morphium/messaging/MessagingImplementationMismatchTest.java b/morphium-core/src/test/java/de/caluga/test/morphium/messaging/MessagingImplementationMismatchTest.java new file mode 100644 index 000000000..58be7783e --- /dev/null +++ b/morphium-core/src/test/java/de/caluga/test/morphium/messaging/MessagingImplementationMismatchTest.java @@ -0,0 +1,154 @@ +package de.caluga.test.morphium.messaging; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.MorphiumConfig; +import de.caluga.morphium.config.MessagingSettings; +import de.caluga.morphium.messaging.DualChannelMessaging; +import de.caluga.morphium.messaging.MessagingParticipant; +import de.caluga.morphium.messaging.MorphiumMessaging; +import de.caluga.morphium.messaging.Msg; +import de.caluga.morphium.messaging.MultiCollectionMessaging; +import de.caluga.morphium.messaging.SingleCollectionMessaging; +import de.caluga.test.mongo.suite.base.MultiDriverTestBase; +import de.caluga.test.mongo.suite.base.TestUtils; + +/** + * Participants on one queue must all run the same messaging implementation - the collection + * layouts differ and there is no bridge (#280). A mismatch used to fail silently ("most things + * work, answers never arrive"). Every instance therefore announces its implementation in the + * layout-independent participants collection and checks the others on startup: WARN by default, + * THROW via {@link MessagingSettings.ImplementationCheck}. + */ +@Tag("messaging") +public class MessagingImplementationMismatchTest extends MultiDriverTestBase { + + /** default queue -> base collection "msg" -> participants collection "msg_participants" */ + private static final String PARTICIPANTS_COLL = "msg_participants"; + + private MorphiumConfig configFor(Morphium base, String impl, MessagingSettings.ImplementationCheck check) { + MorphiumConfig cfg = base.getConfig().createCopy(); + // the two sides live in separate Morphium instances - for the inmem driver they must + // explicitly share the database, otherwise each gets its own private storage (no-op for + // real drivers, which share the database naturally) + cfg.driverSettings().setInMemorySharedDatabases(true); + cfg.messagingSettings().setMessagingImplementation(impl); + cfg.messagingSettings().setMessagingImplementationCheck(check); + cfg.encryptionSettings().setCredentialsEncrypted(base.getConfig().encryptionSettings().getCredentialsEncrypted()); + cfg.encryptionSettings().setCredentialsDecryptionKey(base.getConfig().encryptionSettings().getCredentialsDecryptionKey()); + cfg.encryptionSettings().setCredentialsEncryptionKey(base.getConfig().encryptionSettings().getCredentialsEncryptionKey()); + return cfg; + } + + private void clean(Morphium m) { + m.dropCollection(Msg.class); + m.dropCollection(MessagingParticipant.class, PARTICIPANTS_COLL, null); + TestUtils.waitForConditionToBecomeTrue(5000, "participants collection not dropped", + () -> m.createQueryFor(MessagingParticipant.class, PARTICIPANTS_COLL).countAll() == 0); + } + + @ParameterizedTest + @MethodSource("getMorphiumInstancesNoSingle") + public void participantsAnnounceAndWithdraw(Morphium morphium) throws Exception { + try (morphium) { + try (Morphium m1 = new Morphium(configFor(morphium, SingleCollectionMessaging.NAME, MessagingSettings.ImplementationCheck.WARN)); + Morphium m2 = new Morphium(configFor(morphium, DualChannelMessaging.NAME, MessagingSettings.ImplementationCheck.WARN))) { + clean(m1); + MorphiumMessaging standard = m1.createMessaging(); + MorphiumMessaging dual = m2.createMessaging(); + + try { + standard.start(); + assertTrue(standard.waitForReady(30, TimeUnit.SECONDS), "standard not ready"); + // WARN (the default) must not prevent startup despite the mismatch + dual.start(); + assertTrue(dual.waitForReady(30, TimeUnit.SECONDS), "dual not ready"); + + List participants = + m1.createQueryFor(MessagingParticipant.class, PARTICIPANTS_COLL).asList(); + assertThat(participants).as("every instance announces itself").hasSize(2); + assertThat(participants).extracting(MessagingParticipant::getImplementation) + .containsExactlyInAnyOrder(SingleCollectionMessaging.NAME, DualChannelMessaging.NAME); + assertThat(participants).allSatisfy(p -> { + assertThat(p.getId()).isNotBlank(); + assertThat(p.getLastSeen()).isGreaterThan(0); + }); + } finally { + standard.terminate(); + dual.terminate(); + } + + TestUtils.waitForConditionToBecomeTrue(5000, "participants not withdrawn on terminate", + () -> m1.createQueryFor(MessagingParticipant.class, PARTICIPANTS_COLL).countAll() == 0); + } + } + } + + @ParameterizedTest + @MethodSource("getMorphiumInstancesNoSingle") + public void mismatchThrowsWhenConfigured(Morphium morphium) throws Exception { + try (morphium) { + for (String foreignImpl : List.of(DualChannelMessaging.NAME, MultiCollectionMessaging.NAME)) { + try (Morphium m1 = new Morphium(configFor(morphium, SingleCollectionMessaging.NAME, MessagingSettings.ImplementationCheck.WARN)); + Morphium m2 = new Morphium(configFor(morphium, foreignImpl, MessagingSettings.ImplementationCheck.THROW))) { + clean(m1); + MorphiumMessaging standard = m1.createMessaging(); + MorphiumMessaging foreign = m2.createMessaging(); + + try { + standard.start(); + assertTrue(standard.waitForReady(30, TimeUnit.SECONDS), "standard not ready"); + + assertThatThrownBy(foreign::start) + .as("a %s node joining a StandardMessaging queue must refuse to start with THROW", foreignImpl) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining(SingleCollectionMessaging.NAME); + + // the refused instance must not have left its own announcement behind + List participants = + m1.createQueryFor(MessagingParticipant.class, PARTICIPANTS_COLL).asList(); + assertThat(participants).extracting(MessagingParticipant::getImplementation) + .containsExactly(SingleCollectionMessaging.NAME); + } finally { + standard.terminate(); + foreign.terminate(); + } + } + } + } + } + + @ParameterizedTest + @MethodSource("getMorphiumInstancesNoSingle") + public void sameImplementationPassesThrowCheck(Morphium morphium) throws Exception { + try (morphium) { + try (Morphium m1 = new Morphium(configFor(morphium, SingleCollectionMessaging.NAME, MessagingSettings.ImplementationCheck.THROW)); + Morphium m2 = new Morphium(configFor(morphium, SingleCollectionMessaging.NAME, MessagingSettings.ImplementationCheck.THROW))) { + clean(m1); + MorphiumMessaging first = m1.createMessaging(); + MorphiumMessaging second = m2.createMessaging(); + + try { + first.start(); + assertTrue(first.waitForReady(30, TimeUnit.SECONDS), "first not ready"); + // same implementation everywhere - THROW must not trigger + second.start(); + assertTrue(second.waitForReady(30, TimeUnit.SECONDS), "second not ready"); + } finally { + first.terminate(); + second.terminate(); + } + } + } + } +} diff --git a/morphium-core/src/test/java/de/caluga/test/morphium/messaging/TopicFilterChangeStreamTest.java b/morphium-core/src/test/java/de/caluga/test/morphium/messaging/TopicFilterChangeStreamTest.java new file mode 100644 index 000000000..cc9446678 --- /dev/null +++ b/morphium-core/src/test/java/de/caluga/test/morphium/messaging/TopicFilterChangeStreamTest.java @@ -0,0 +1,266 @@ +package de.caluga.test.morphium.messaging; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.MorphiumConfig; +import de.caluga.morphium.messaging.DualChannelMessaging; +import de.caluga.morphium.messaging.MessageListener; +import de.caluga.morphium.messaging.MorphiumMessaging; +import de.caluga.morphium.messaging.Msg; +import de.caluga.morphium.messaging.SingleCollectionMessaging; +import de.caluga.test.mongo.suite.base.MultiDriverTestBase; +import de.caluga.test.mongo.suite.base.TestUtils; + +@Tag("messaging") +public class TopicFilterChangeStreamTest extends MultiDriverTestBase { + + private static final List IMPLEMENTATIONS = List.of(SingleCollectionMessaging.NAME, DualChannelMessaging.NAME); + + private MorphiumConfig configFor(Morphium base, String impl) { + MorphiumConfig cfg = base.getConfig().createCopy(); + cfg.messagingSettings().setMessagingImplementation(impl); + cfg.encryptionSettings().setCredentialsEncrypted(base.getConfig().encryptionSettings().getCredentialsEncrypted()); + cfg.encryptionSettings().setCredentialsDecryptionKey(base.getConfig().encryptionSettings().getCredentialsDecryptionKey()); + cfg.encryptionSettings().setCredentialsEncryptionKey(base.getConfig().encryptionSettings().getCredentialsEncryptionKey()); + return cfg; + } + + private Set csFilterTopics(MorphiumMessaging messaging) { + if (messaging instanceof SingleCollectionMessaging scm) return scm.getCsFilterTopics(); + if (messaging instanceof DualChannelMessaging dcm) return dcm.getCsFilterTopics(); + throw new IllegalArgumentException("unexpected messaging implementation: " + messaging.getClass()); + } + + @ParameterizedTest + @MethodSource("getMorphiumInstancesNoSingle") + public void listenedTopicDeliveredForeignTopicNot(Morphium morphium) throws Exception { + try (morphium) { + for (String impl : IMPLEMENTATIONS) { + log.info("=====> listenedTopicDeliveredForeignTopicNot with " + impl); + + try (Morphium m = new Morphium(configFor(morphium, impl))) { + m.dropCollection(Msg.class); + MorphiumMessaging sender = m.createMessaging(); + MorphiumMessaging receiver = m.createMessaging(); + AtomicInteger gotListened = new AtomicInteger(0); + AtomicInteger gotForeign = new AtomicInteger(0); + + try { + sender.start(); + assertTrue(sender.waitForReady(30, TimeUnit.SECONDS), "sender not ready"); + receiver.start(); + assertTrue(receiver.waitForReady(30, TimeUnit.SECONDS), "receiver not ready"); + + receiver.addListenerForTopic("tf_listened", (mm, msg) -> { + gotListened.incrementAndGet(); + return null; + }); + + sender.sendMessage(new Msg("tf_foreign", "msg", "value")); + sender.sendMessage(new Msg("tf_listened", "msg", "value")); + + TestUtils.waitForConditionToBecomeTrue(15000, "listened topic not delivered (" + impl + ")", + () -> gotListened.get() == 1); + // the foreign message was sent BEFORE the listened one and both took the same + // path - if it were going to be delivered, it would have arrived by now + Thread.sleep(1000); + assertEquals(0, gotForeign.get(), "message without listener must not be delivered (" + impl + ")"); + assertEquals(1, gotListened.get(), "listened message delivered exactly once (" + impl + ")"); + } finally { + sender.terminate(); + receiver.terminate(); + } + } + } + } + } + + @ParameterizedTest + @MethodSource("getMorphiumInstancesNoSingle") + public void filterRebuildsOnLateListenerRegistration(Morphium morphium) throws Exception { + try (morphium) { + for (String impl : IMPLEMENTATIONS) { + log.info("=====> filterRebuildsOnLateListenerRegistration with " + impl); + + try (Morphium m = new Morphium(configFor(morphium, impl))) { + m.dropCollection(Msg.class); + MorphiumMessaging sender = m.createMessaging(); + MorphiumMessaging receiver = m.createMessaging(); + AtomicInteger gotB = new AtomicInteger(0); + + try { + sender.start(); + assertTrue(sender.waitForReady(30, TimeUnit.SECONDS), "sender not ready"); + receiver.start(); + assertTrue(receiver.waitForReady(30, TimeUnit.SECONDS), "receiver not ready"); + + receiver.addListenerForTopic("tf_a", (mm, msg) -> null); + TestUtils.waitForConditionToBecomeTrue(15000, "filter not rebuilt for tf_a (" + impl + ")", + () -> csFilterTopics(receiver).contains("tf_a")); + + // message sent before tf_b is registered - must be picked up on registration + sender.sendMessage(new Msg("tf_b", "msg", "early")); + Thread.sleep(500); + assertEquals(0, gotB.get(), "tf_b has no listener yet (" + impl + ")"); + + receiver.addListenerForTopic("tf_b", (mm, msg) -> { + gotB.incrementAndGet(); + return null; + }); + TestUtils.waitForConditionToBecomeTrue(15000, "pre-registration tf_b message not picked up (" + impl + ")", + () -> gotB.get() == 1); + TestUtils.waitForConditionToBecomeTrue(15000, "filter not rebuilt for tf_b (" + impl + ")", + () -> csFilterTopics(receiver).contains("tf_b")); + + // now the rebuilt change stream must deliver new tf_b messages + sender.sendMessage(new Msg("tf_b", "msg", "late")); + TestUtils.waitForConditionToBecomeTrue(15000, "post-rebuild tf_b message not delivered (" + impl + ")", + () -> gotB.get() == 2); + + Set topics = csFilterTopics(receiver); + assertTrue(topics.contains("tf_a") && topics.contains("tf_b"), + "filter topics must track registered listeners (" + impl + "), got: " + topics); + } finally { + sender.terminate(); + receiver.terminate(); + } + } + } + } + } + + @ParameterizedTest + @MethodSource("getMorphiumInstancesNoSingle") + public void broadcastAnswerBypassesTopicFilter(Morphium morphium) throws Exception { + try (morphium) { + for (String impl : IMPLEMENTATIONS) { + log.info("=====> broadcastAnswerBypassesTopicFilter with " + impl); + + try (Morphium m = new Morphium(configFor(morphium, impl))) { + m.dropCollection(Msg.class); + MorphiumMessaging requester = m.createMessaging(); + MorphiumMessaging responder = m.createMessaging(); + + try { + requester.start(); + assertTrue(requester.waitForReady(30, TimeUnit.SECONDS), "requester not ready"); + responder.start(); + assertTrue(responder.waitForReady(30, TimeUnit.SECONDS), "responder not ready"); + + responder.addListenerForTopic("tf_req", (mm, msg) -> { + // craft a BROADCAST answer: inAnswerTo set, no recipient, and a topic + // the requester has no listener for - must still reach its waiter + Msg ans = new Msg("tf_unrelated", "answer", "value"); + ans.setInAnswerTo(msg.getMsgId()); + mm.sendMessage(ans); + return null; + }); + + Msg answer = requester.sendAndAwaitFirstAnswer(new Msg("tf_req", "question", "value"), 15000); + assertTrue(answer != null && "tf_unrelated".equals(answer.getTopic()), + "broadcast answer on unlistened topic must reach the waiter (" + impl + ")"); + } finally { + requester.terminate(); + responder.terminate(); + } + } + } + } + } + + @ParameterizedTest + @MethodSource("getMorphiumInstancesNoSingle") + public void v5LegacyNameOnlyBroadcastPassesFilter(Morphium morphium) throws Exception { + try (morphium) { + for (String impl : IMPLEMENTATIONS) { + log.info("=====> v5LegacyNameOnlyBroadcastPassesFilter with " + impl); + + try (Morphium m = new Morphium(configFor(morphium, impl))) { + m.dropCollection(Msg.class); + MorphiumMessaging receiver = m.createMessaging(); + AtomicInteger got = new AtomicInteger(0); + + try { + receiver.start(); + assertTrue(receiver.waitForReady(30, TimeUnit.SECONDS), "receiver not ready"); + receiver.addListenerForTopic("tf_legacy", (mm, msg) -> { + got.incrementAndGet(); + return null; + }); + TestUtils.waitForConditionToBecomeTrue(15000, "filter not rebuilt for tf_legacy", + () -> csFilterTopics(receiver).contains("tf_legacy")); + + // a pre-6.x sender stores only "name" - no "topic" field on the document. + // The change-stream filter must pass it; postLoad() maps name -> topic only + // client-side. Delivery must be CS-prompt, well below the fallback interval. + java.util.Map v5Doc = new java.util.HashMap<>(); + v5Doc.put("name", "tf_legacy"); + v5Doc.put("msg", "legacy message"); + v5Doc.put("value", "v5_value"); + v5Doc.put("sender", "v5_sender"); + v5Doc.put("senderHost", "v5_host"); + v5Doc.put("timestamp", System.currentTimeMillis()); + v5Doc.put("ttl", 30000L); + v5Doc.put("priority", 1000); + v5Doc.put("timingOut", true); + v5Doc.put("deleteAfterProcessing", false); + v5Doc.put("deleteAfterProcessingTime", 0); + v5Doc.put("exclusive", false); + v5Doc.put("processedBy", null); + v5Doc.put("recipients", null); + v5Doc.put("inAnswerTo", null); + m.storeMap(receiver.getCollectionName(), v5Doc); + + TestUtils.waitForConditionToBecomeTrue(5000, + "name-only legacy broadcast not delivered promptly (" + impl + ")", + () -> got.get() == 1); + } finally { + receiver.terminate(); + } + } + } + } + } + + @ParameterizedTest + @MethodSource("getMorphiumInstancesNoSingle") + public void filterShrinksOnListenerRemoval(Morphium morphium) throws Exception { + try (morphium) { + for (String impl : IMPLEMENTATIONS) { + log.info("=====> filterShrinksOnListenerRemoval with " + impl); + + try (Morphium m = new Morphium(configFor(morphium, impl))) { + m.dropCollection(Msg.class); + MorphiumMessaging receiver = m.createMessaging(); + MessageListener listener = (mm, msg) -> null; + + try { + receiver.start(); + assertTrue(receiver.waitForReady(30, TimeUnit.SECONDS), "receiver not ready"); + + receiver.addListenerForTopic("tf_tmp", listener); + TestUtils.waitForConditionToBecomeTrue(15000, "filter not rebuilt after add (" + impl + ")", + () -> csFilterTopics(receiver).contains("tf_tmp")); + + receiver.removeListenerForTopic("tf_tmp", listener); + TestUtils.waitForConditionToBecomeTrue(15000, "filter not rebuilt after remove (" + impl + ")", + () -> !csFilterTopics(receiver).contains("tf_tmp")); + } finally { + receiver.terminate(); + } + } + } + } + } +} diff --git a/morphium-core/src/test/java/de/caluga/test/morphium/messaging/TopicRegistryTest.java b/morphium-core/src/test/java/de/caluga/test/morphium/messaging/TopicRegistryTest.java index 4290ea1be..964ca0e09 100644 --- a/morphium-core/src/test/java/de/caluga/test/morphium/messaging/TopicRegistryTest.java +++ b/morphium-core/src/test/java/de/caluga/test/morphium/messaging/TopicRegistryTest.java @@ -123,7 +123,7 @@ public void testSuccessfulSendWithListener(Morphium morphium) throws Exception { sender.sendMessage(new Msg("listener-topic", "msg", "value")); Thread.sleep(1000); // Wait for message processing - assert (received.get()); + assertTrue((received.get())); sender.terminate(); receiver.terminate(); diff --git a/morphium-core/src/test/java/de/caluga/test/morphium/testutil/proxy/WireProxyTest.java b/morphium-core/src/test/java/de/caluga/test/morphium/testutil/proxy/WireProxyTest.java index 6d4063bea..7e3cfa824 100644 --- a/morphium-core/src/test/java/de/caluga/test/morphium/testutil/proxy/WireProxyTest.java +++ b/morphium-core/src/test/java/de/caluga/test/morphium/testutil/proxy/WireProxyTest.java @@ -177,13 +177,23 @@ void resetOnANewConnectionIsRefusedOutright() throws Exception { proxy.start(); proxy.setFaultMode(FaultMode.reset); + // The proxy accepts and immediately severs with an RST (SO_LINGER=0). WHERE that RST + // surfaces on the client depends on timing: usually the TCP handshake completes from + // the backlog, connect() succeeds, and the first read fails - but if the RST is + // already pending when connect()'s internal poll runs (~1% of attempts even on an idle + // machine, more under CI load), connect() itself throws SocketException. Both are + // valid "refused outright" outcomes; only a timeout or an actual reply would be wrong. try (Socket s = new Socket()) { - s.connect(new InetSocketAddress("localhost", proxy.getListenPort()), 2000); + try { + s.connect(new InetSocketAddress("localhost", proxy.getListenPort()), 2000); + } catch (java.net.SocketException e) { + return; // RST raced the connect itself - equally a hard refusal + } s.setSoTimeout(1500); - // The socket may connect (TCP accept happened), but any read must fail fast with a - // reset/EOF-shaped error - never time out, never return a reply. - assertThrows(IOException.class, + IOException e = assertThrows(IOException.class, () -> WireProtocolMessage.parseFromStream(s.getInputStream())); + assertFalse(e instanceof java.net.SocketTimeoutException, + "reset mode must fail fast with a reset/EOF-shaped error, not time out"); } } diff --git a/morphium-core/src/test/java/de/caluga/test/objectmapping/ObjectMapperTest.java b/morphium-core/src/test/java/de/caluga/test/objectmapping/ObjectMapperTest.java index 7fe85b8c5..d03c3d16c 100644 --- a/morphium-core/src/test/java/de/caluga/test/objectmapping/ObjectMapperTest.java +++ b/morphium-core/src/test/java/de/caluga/test/objectmapping/ObjectMapperTest.java @@ -58,19 +58,19 @@ public void marshallListOfIdsTest() { c.idMap.put("1", new MorphiumId()); MorphiumObjectMapper mapper = new ObjectMapperImpl(); Map marshall = mapper.serialize(c); - assert(marshall.get("simple_id") instanceof ObjectId); - assert(((Map ) marshall.get("id_map")).get("1") instanceof ObjectId); + assertTrue((marshall.get("simple_id") instanceof ObjectId)); + assertTrue((((Map ) marshall.get("id_map")).get("1") instanceof ObjectId)); for (Object i : (List) marshall.get("others")) { - assert(i instanceof ObjectId); + assertTrue((i instanceof ObjectId)); } /// c = mapper.deserialize(ListOfIdsContainer.class, marshall); // noinspection ConstantConditions - assert(c.idMap != null && c.idMap.get("1") != null && c.idMap.get("1") instanceof MorphiumId); + assertTrue((c.idMap != null && c.idMap.get("1") != null && c.idMap.get("1") instanceof MorphiumId)); // noinspection ConstantConditions - assert(c.others.size() == 4 && c.others.get(0) instanceof MorphiumId); + assertTrue((c.others.size() == 4 && c.others.get(0) instanceof MorphiumId)); assertNotNull(c.simpleId);; } @@ -92,20 +92,20 @@ public void mapSerializationTest() { om.setAnnotationHelper(an); Map map = om.serialize(new ObjectMapperImplTest.Simple()); log.info("Got map"); - assert(map.get("test").toString().startsWith("test")); + assertTrue((map.get("test").toString().startsWith("test"))); ObjectMapperImplTest.Simple s = om.deserialize(ObjectMapperImplTest.Simple.class, map); log.info("Got simple"); Map m = new HashMap<>(); m.put("test", "testvalue"); m.put("simple", s); map = om.serializeMap(m, null); - assert(map.get("test").equals("testvalue")); + assertTrue((map.get("test").equals("testvalue"))); List lst = new ArrayList<>(); lst.add(new ObjectMapperImplTest.Simple()); lst.add(new ObjectMapperImplTest.Simple()); lst.add(new ObjectMapperImplTest.Simple()); List serializedList = om.serializeIterable(lst, null, null); - assert(serializedList.size() == 3); + assertTrue((serializedList.size() == 3)); List deserializedList = om.deserializeList(serializedList); log.info("Deserialized " + deserializedList.size()); } diff --git a/morphium-jakarta-data/pom.xml b/morphium-jakarta-data/pom.xml index c559ee1fd..4a8fcfbaa 100644 --- a/morphium-jakarta-data/pom.xml +++ b/morphium-jakarta-data/pom.xml @@ -1,13 +1,10 @@ - + 4.0.0 - de.caluga morphium-parent - 6.3.0-SNAPSHOT + 6.3.2-SNAPSHOT morphium-jakarta-data jar @@ -29,7 +26,6 @@ slf4j-api - org.junit.jupiter junit-jupiter diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/FindMethodBridge.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/FindMethodBridge.java index 71a2e80f7..30d317a3d 100644 --- a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/FindMethodBridge.java +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/FindMethodBridge.java @@ -271,7 +271,10 @@ private static Object executeCursoredFind(Query query, PageRequest pageRequest, } /** - * Executes a {@code @Delete} annotated method with {@code @By} parameters. + * Executes a {@code @Delete} annotated method with {@code @By} parameters, without + * reporting how many entities were removed. Kept for callers whose method is declared + * {@code void} (Jakarta Data permits {@code void}, {@code int}, or {@code long} for a + * parameter-based {@code @Delete} method). * * @param repo the repository instance * @param conditionsSpec encoded conditions (same format as executeFind) @@ -281,6 +284,23 @@ private static Object executeCursoredFind(Query query, PageRequest pageRequest, public static void executeAnnotatedDelete(AbstractMorphiumRepository repo, String conditionsSpec, Object[] args) { + executeAnnotatedDeleteCounted(repo, conditionsSpec, args); + } + + /** + * Executes a {@code @Delete} annotated method with {@code @By} parameters and returns the + * number of deleted entities. Jakarta Data requires a parameter-based {@code @Delete} + * method declared {@code int} or {@code long} to return this count. + * + * @param repo the repository instance + * @param conditionsSpec encoded conditions (same format as executeFind) + * @param args the method arguments + * @return the number of entities deleted + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public static long executeAnnotatedDeleteCounted(AbstractMorphiumRepository repo, + String conditionsSpec, + Object[] args) { Morphium morphium = repo.getMorphium(); Class entityClass = repo.getMetadata().entityClass(); Query query = morphium.createQueryFor(entityClass); @@ -295,10 +315,15 @@ public static void executeAnnotatedDelete(AbstractMorphiumRepository repo, } } - List toDelete = query.asList(); - for (Object entity : toDelete) { - morphium.delete(entity); - } + // Query-based delete (single round-trip, server-side) instead of loading every matching + // entity into memory and deleting one by one: more efficient for large deletes, and more + // accurate -- "n" below is the driver's own count of documents actually removed, whereas + // counting the entities loaded by a prior query() would drift from the real delete count + // under concurrent modification (a document deleted or changed by another writer between + // the load and the per-entity delete). + Map result = query.delete(); + Object n = result == null ? null : result.get("n"); + return n instanceof Number num ? num.longValue() : 0L; } /** diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryExecutor.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryExecutor.java index ed535899b..8c3377386 100644 --- a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryExecutor.java +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryExecutor.java @@ -198,7 +198,7 @@ static void applySorting(Query query, } @SuppressWarnings("unchecked") - private static String resolveMongoField(Morphium morphium, + static String resolveMongoField(Morphium morphium, Class entityClass, String javaFieldName) { try { diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryMethodBridge.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryMethodBridge.java index b0dab1dc3..6a83dbd66 100644 --- a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryMethodBridge.java +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryMethodBridge.java @@ -1,7 +1,16 @@ package de.caluga.morphium.data; +import de.caluga.morphium.Morphium; +import de.caluga.morphium.query.Query; +import jakarta.data.Limit; +import jakarta.data.Order; +import jakarta.data.Sort; +import jakarta.data.page.PageRequest; + import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.concurrent.ConcurrentHashMap; @@ -47,6 +56,166 @@ public static Object executeQuery(AbstractMorphiumRepository repo, return executeQuery(repo, methodName, args, returnsSingle, returnsOptional, returnsBoolean, returnsStream, ""); } + /** + * Called from generated bytecode for each derived query method invocation that declares a + * dynamic {@code Sort}, {@code Order}, {@code PageRequest}, or {@code Limit} parameter. + * Unlike the simpler overloads above (which delegate entirely to {@link QueryExecutor#execute} + * and support only method-name-derived and {@code @OrderBy}-annotation-derived ordering), + * this overload builds the query itself so a dynamic parameter can be applied on top: a + * {@code Sort}/{@code Order} argument overrides the parsed ordering (mirroring how + * {@link FindMethodBridge#executeFind} treats a dynamic {@code Sort}/{@code Order} argument as + * taking precedence over a static {@code @OrderBy}), a {@code Limit} argument applies + * {@code skip}/{@code limit}, and a {@code PageRequest} argument returns a {@link MorphiumPage} + * instead of the plain result shape. + * + * @param repo the repository instance (provides Morphium + metadata) + * @param methodName the repository method name (e.g. "findByStatus") + * @param args the method arguments + * @param returnsSingle whether the caller expects a single result (T) + * @param returnsOptional whether the caller expects an Optional result + * @param returnsBoolean whether the caller expects a boolean result (for deleteBy*) + * @param returnsStream whether the caller expects a Stream result + * @param orderBySpec the {@code @OrderBy} annotation spec (e.g. "createdAt:DESC"), "" for none + * @param sortParamIndex index of a {@code Sort} parameter, -1 if absent + * @param orderParamIndex index of an {@code Order} parameter, -1 if absent + * @param pageRequestParamIndex index of a {@code PageRequest} parameter, -1 if absent + * @param limitParamIndex index of a {@code Limit} parameter, -1 if absent + * @return the query result + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public static Object executeQuery(AbstractMorphiumRepository repo, + String methodName, + Object[] args, + boolean returnsSingle, + boolean returnsOptional, + boolean returnsBoolean, + boolean returnsStream, + String orderBySpec, + int sortParamIndex, + int orderParamIndex, + int pageRequestParamIndex, + int limitParamIndex) { + if (sortParamIndex < 0 && orderParamIndex < 0 && pageRequestParamIndex < 0 && limitParamIndex < 0) { + // No dynamic parameter present -- identical to the simpler overload, avoids + // building the query twice (once here, once inside QueryExecutor.execute). + return executeQuery(repo, methodName, args, returnsSingle, returnsOptional, + returnsBoolean, returnsStream, orderBySpec); + } + + String cacheKey = repo.getMetadata().entityClass().getName() + "#" + methodName + + (orderBySpec.isEmpty() ? "" : "#" + orderBySpec); + QueryDescriptor descriptor = CACHE.computeIfAbsent(cacheKey, k -> { + QueryDescriptor parsed = MethodNameParser.parse(methodName, null); + if (!orderBySpec.isEmpty()) { + var mergedOrderBy = new ArrayList<>(parsed.orderBy()); + mergedOrderBy.addAll(parseOrderBySpec(orderBySpec)); + return new QueryDescriptor(parsed.prefix(), parsed.conditions(), + parsed.combinator(), mergedOrderBy, parsed.returnType()); + } + return parsed; + }); + + // Regression fix (see commit 11f669e77 review, PR #267): this overload used to always + // fall through to a plain find (query.asList()/asBoolean-from-list-emptiness) further + // down, no matter what descriptor.prefix() said. That is correct for FIND, but for + // COUNT/EXISTS/DELETE it silently turned a countBy*/existsBy*/deleteBy* method with a + // dynamic Sort/Order/PageRequest/Limit parameter into a find: countBy*/existsBy* got a + // List back where a Long/boolean was expected (ClassCastException at the generated + // checkCast), and deleteBy* stopped deleting anything at all while still reporting + // success. + // + // Decision: for any non-FIND prefix, delegate to the same QueryExecutor.execute() path + // used by the simpler (no-dynamic-parameter) overload below, which already implements + // the correct DELETE/COUNT/EXISTS semantics (actually deletes, returns a count/boolean, + // never a List). This *does* take the dynamic parameters into account: a dynamic + // Sort/Order argument is harmless to accept-and-ignore here (there is no result set on a + // count, an existence check, or a bulk delete for it to reorder — same reasoning as a + // static/method-name-derived order-by, which QueryExecutor.execute() already applies + // only for FIND), so simply not passing it through is the correct, side-effect-free + // behaviour. A dynamic Limit or PageRequest argument on a non-FIND prefix, however, is + // semantically questionable (what would "the 3rd page of a delete" or "count, but only + // the first 10" mean?) and is not sensibly supportable — that combination is therefore + // rejected at BUILD TIME in MorphiumDataProcessor.generateQueryMethod(), so it can never + // reach this method; if it ever did, it would be silently ignored below, same as Sort/ + // Order, since limitParamIndex/pageRequestParamIndex are simply not read on this branch. + if (descriptor.prefix() != QueryDescriptor.Prefix.FIND) { + Object result = QueryExecutor.execute(descriptor, args, repo); + // For deleteBy* with boolean return: convert count > 0 (mirrors the equivalent + // conversion in the simpler overload below). + if (returnsBoolean && result instanceof Long count) { + return count > 0; + } + return result; + } + + Morphium morphium = repo.getMorphium(); + Class entityClass = repo.getMetadata().entityClass(); + Query query = morphium.createQueryFor(entityClass); + QueryExecutor.applyConditions(query, descriptor, args, morphium, entityClass); + + // Static (method-name-derived / @OrderBy) ordering first -- a dynamic Sort/Order + // argument below overrides it, same precedence as FindMethodBridge.executeFind. + if (descriptor.orderBy() != null && !descriptor.orderBy().isEmpty()) { + QueryExecutor.applySorting(query, descriptor.orderBy(), morphium, entityClass); + } + + if (sortParamIndex >= 0 && args[sortParamIndex] != null) { + Sort sort = (Sort) args[sortParamIndex]; + Map sortMap = new LinkedHashMap<>(); + String mongoField = QueryExecutor.resolveMongoField(morphium, entityClass, sort.property()); + sortMap.put(mongoField, sort.isAscending() ? 1 : -1); + query.sort(sortMap); + } + if (orderParamIndex >= 0 && args[orderParamIndex] != null) { + Order order = (Order) args[orderParamIndex]; + if (!order.sorts().isEmpty()) { + Map sortMap = new LinkedHashMap<>(); + for (Object s : order.sorts()) { + Sort sort = (Sort) s; + String mongoField = QueryExecutor.resolveMongoField(morphium, entityClass, sort.property()); + sortMap.put(mongoField, sort.isAscending() ? 1 : -1); + } + query.sort(sortMap); + } + } + if (limitParamIndex >= 0 && args[limitParamIndex] != null) { + Limit limit = (Limit) args[limitParamIndex]; + query.skip((int) (limit.startAt() - 1)); + query.limit(limit.maxResults()); + } + if (pageRequestParamIndex >= 0 && args[pageRequestParamIndex] != null) { + PageRequest pageRequest = (PageRequest) args[pageRequestParamIndex]; + int size = pageRequest.size(); + long page = pageRequest.page(); + int skip = (int) ((page - 1) * size); + query.skip(skip).limit(size); + + List content = query.asList(); + long totalElements = -1; + if (pageRequest.requestTotal()) { + Query countQuery = morphium.createQueryFor(entityClass); + QueryExecutor.applyConditions(countQuery, descriptor, args, morphium, entityClass); + totalElements = countQuery.countAll(); + } + return new MorphiumPage<>(content, totalElements, pageRequest); + } + + if (returnsOptional) { + return QueryResultHelper.optionalSingle(query); + } + if (returnsSingle) { + return QueryResultHelper.requireSingle(query); + } + if (returnsStream) { + return query.stream(); + } + List resultList = query.asList(); + if (returnsBoolean) { + return !resultList.isEmpty(); + } + return resultList; + } + /** * Called from generated bytecode for each derived query method invocation. * Overload that accepts an {@code @OrderBy} annotation spec to merge with @@ -177,6 +346,45 @@ public static CompletionStage executeQueryAsync(AbstractMorphiumReposito repo.getAsyncExecutor()); } + /** + * Asynchronous variant of {@link #executeQuery(AbstractMorphiumRepository, String, Object[], + * boolean, boolean, boolean, boolean, String, int, int, int, int)}, running the query on the + * repository's async executor. Used for derived query methods that declare a dynamic + * {@code Sort}, {@code Order}, {@code PageRequest}, or {@code Limit} parameter and a + * {@code CompletionStage} return type. + * + * @param repo the repository instance (provides Morphium + metadata) + * @param methodName the repository method name (e.g. "findByStatus") + * @param args the method arguments + * @param returnsSingle whether the caller expects a single result (T) + * @param returnsOptional whether the caller expects an Optional result + * @param returnsBoolean whether the caller expects a boolean result (for deleteBy*) + * @param returnsStream whether the caller expects a Stream result + * @param orderBySpec the {@code @OrderBy} annotation spec (e.g. "createdAt:DESC") + * @param sortParamIndex index of a {@code Sort} parameter, -1 if absent + * @param orderParamIndex index of an {@code Order} parameter, -1 if absent + * @param pageRequestParamIndex index of a {@code PageRequest} parameter, -1 if absent + * @param limitParamIndex index of a {@code Limit} parameter, -1 if absent + * @return a completion stage yielding the query result + */ + public static CompletionStage executeQueryAsync(AbstractMorphiumRepository repo, + String methodName, + Object[] args, + boolean returnsSingle, + boolean returnsOptional, + boolean returnsBoolean, + boolean returnsStream, + String orderBySpec, + int sortParamIndex, + int orderParamIndex, + int pageRequestParamIndex, + int limitParamIndex) { + return CompletableFuture.supplyAsync( + () -> executeQuery(repo, methodName, args, returnsSingle, returnsOptional, returnsBoolean, returnsStream, + orderBySpec, sortParamIndex, orderParamIndex, pageRequestParamIndex, limitParamIndex), + repo.getAsyncExecutor()); + } + /** * Parses the build-time orderBy spec string (e.g. "createdAt:DESC,name:ASC") * into a list of {@link QueryDescriptor.OrderSpec}. diff --git a/pom.xml b/pom.xml index 27dd9ec93..fc9450cd8 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 de.caluga morphium-parent - 6.3.0-SNAPSHOT + 6.3.2-SNAPSHOT pom Morphium Parent http://caluga.de @@ -21,7 +21,7 @@ https://github.com/sboesebeck/morphium scm:git:git://github.com/sboesebeck/morphium.git scm:git:git@github.com:sboesebeck/morphium.git - v6.2.7 + HEAD @@ -32,7 +32,7 @@ morphium-core @@ -60,16 +78,52 @@ UTF-8 4.11.5 4.2.9.Final - - + + + + -Xmx2g - - external,manual + external = needs a real MongoDB (enabled by -Pexternal) + manual = process-killing / hardcoded-local tests, NEVER in CI + benchmark = timing-sensitive perf benchmarks (e.g. PerformanceBenchmarkTest), not part + of the regular suite; run explicitly via -Dtest=(class name) or the + "tags benchmark" runtests.sh option --> + + external,manual,benchmark 1.0.0 + + 3.32.3 + + 3.4.13 @@ -79,7 +133,7 @@ maven-surefire-plugin 3.0.0 - ${argLine} + @{argLine} ${test.maxHeap} ${test.includeTags} ${test.excludeTags} @@ -164,6 +218,11 @@ -Dmaven.javadoc.skip=false -DskipTests + + org.jacoco + jacoco-maven-plugin + 0.8.12 + @@ -365,8 +424,8 @@ external - - manual + + manual,benchmark @@ -400,9 +459,38 @@ single - + + coverage + + + + + org.jacoco + jacoco-maven-plugin + + + prepare-agent + prepare-agent + + + report + verify + report + + + + + + + extensions @@ -412,6 +500,8 @@ morphium-jakarta-data + quarkus-morphium + spring-boot-morphium diff --git a/poppydb/pom.xml b/poppydb/pom.xml index 9beddcbab..3dca89cdb 100644 --- a/poppydb/pom.xml +++ b/poppydb/pom.xml @@ -4,7 +4,7 @@ de.caluga morphium-parent - 6.3.0-SNAPSHOT + 6.3.2-SNAPSHOT poppydb jar diff --git a/poppydb/src/main/java/de/caluga/poppydb/ConfigInspector.java b/poppydb/src/main/java/de/caluga/poppydb/ConfigInspector.java index 49f7e0061..07ce139ad 100644 --- a/poppydb/src/main/java/de/caluga/poppydb/ConfigInspector.java +++ b/poppydb/src/main/java/de/caluga/poppydb/ConfigInspector.java @@ -52,6 +52,11 @@ static Result validate(ServerOptions opts) { if (opts.maxBsonSizeBytes < 0) { errors.add("max-bson-size must be >= 0 (0 = off), got: " + opts.maxBsonSizeBytes); } + try { + opts.replayBufferBytes(); + } catch (IllegalArgumentException e) { + errors.add(e.getMessage()); + } if (opts.maxConnections < 1) { errors.add("max-connections must be >= 1, got: " + opts.maxConnections); } @@ -171,6 +176,16 @@ static String render(ServerOptions opts, Path configFile) { appendKey(sb, opts, "memory-warn", String.valueOf(opts.memoryWarnPct)); appendKey(sb, opts, "memory-reject", String.valueOf(opts.memoryRejectPct)); appendKey(sb, opts, "max-bson-size", String.valueOf(opts.maxBsonSizeBytes)); + appendKey(sb, opts, "replay-buffer", opts.replayBuffer); + + // Resolved value as a comment only - the rendered output must stay a loadable config + // file, so the key keeps its raw input form (a percentage resolves against max heap). + try { + sb.append("# replay-buffer resolved: ").append(opts.replayBufferBytes()).append(" bytes\n"); + } catch (IllegalArgumentException e) { + // invalid value - validate() reports it, nothing to resolve here + } + appendKey(sb, opts, "compressor", opts.compressor.toLowerCase(Locale.ROOT)); appendKey(sb, opts, "rs-name", opts.rsName); appendKey(sb, opts, "rs-seed", opts.rsSeed); diff --git a/poppydb/src/main/java/de/caluga/poppydb/PoppyDB.java b/poppydb/src/main/java/de/caluga/poppydb/PoppyDB.java index 56ae4df14..3ca59c0b0 100644 --- a/poppydb/src/main/java/de/caluga/poppydb/PoppyDB.java +++ b/poppydb/src/main/java/de/caluga/poppydb/PoppyDB.java @@ -95,6 +95,16 @@ public class PoppyDB { // synchronized body - by definition nothing newer can exist to make it stale later, so the // most recent transition is never starved by this guard, only strictly older ones are. private final java.util.concurrent.atomic.AtomicLong leadershipEpoch = new java.util.concurrent.atomic.AtomicLong(0); + // Guards the epoch-increment + primary-flip pair in applyLeadershipFlip (and the startup + // poll's mirror write in waitForElectionResult). Without it, the two operations are + // individually atomic but not jointly: a stale onLeadershipChange dispatch could increment + // first, get preempted, and write its outdated primary value AFTER a newer transition + // already wrote the current one - leaving e.g. a demoted leader with primary==true forever, + // which no-ops startReplicationToLeader/probeReplicationLiveness/retryReplicationStart and + // silently stops replication. Deliberately NOT the PoppyDB monitor: the flip must stay + // cheap and must keep happening before the transition body competes for the monitor (see + // onLeadershipChange). + private final Object leadershipFlagLock = new Object(); // Election configuration private boolean electionEnabled = false; @@ -132,6 +142,27 @@ public class PoppyDB { // volatile: mutated under synchronized on the election/leadership paths but read unsynchronized // from Netty event-loop threads via the isSecondarySyncing() supplier passed to each handler. private volatile ReplicationManager replicationManager = null; + // Durable carry-over watermark for ReplicationManager#carryOverLastAppliedSequence (2026-08-14 + // review hardening). startReplicationToLeader() reads the predecessor RM's lastAppliedSequence + // into a purely LOCAL variable before building the replacement - which dies with the attempt + // if newReplicationManager.start() then throws (real, if narrow: an auth/TLS connect failure). + // replicationManager stays null in that case, so the retry chain's NEXT + // startReplicationToLeader() call would otherwise read 0 again, silently making the + // destructive-resync guard vacuous on the retry. This field persists that value across such + // retries independent of whether any particular attempt ever successfully starts - updated + // every time startReplicationToLeader() reads (and stops) a predecessor, so it always reflects + // the most recently known-good position. volatile: written only under the class monitor + // (synchronized methods), but read here defensively for the same reason replicationManager is. + private volatile long lastKnownAppliedSequence = 0; + // Companion to lastKnownAppliedSequence above (2026-08-14 production-CI fix, I-2): the + // "host:port" the watermark sequence was actually earned against - see + // ReplicationManager#carryOverLastAppliedSequence(long, String)'s javadoc for why comparing + // sequences across a genuine leader change is unsound (production incident: 82 refusal loops + // over 40+ minutes). Always updated TOGETHER with lastKnownAppliedSequence, from the same + // predecessor read, so the pair is never inconsistent with each other. null means "no + // predecessor ever recorded" (cold boot) - carryOverSourceFor()'s null result correctly never + // matches any real ReplicationManager#getLeaderAddress(). + private volatile String lastKnownAppliedSequenceSource = null; // Held behind an AtomicReference (rather than a plain volatile field copied into each // connection at accept time) so every MongoCommandHandler resolves the coordinator live // via a Supplier - onLeadershipChange swaps this reference and every existing connection @@ -184,13 +215,31 @@ public PoppyDB(int port, String host, int maxConnections, int idleTimeoutSeconds driver.setServerMode(true); // Size the change-event replay buffer for replication resume-after-disconnect: a reconnecting // secondary replays events after its last-applied sequence from this buffer instead of doing a - // full re-sync. Bound: 100_000 events (ring buffer, oldest evicted on overflow). + // full re-sync. Bounds: 100_000 events AND a byte budget (ring buffer, oldest evicted on + // overflow of either). The count limit alone does not bound memory - every buffered event + // retains its full document, so 100k bulk-write events pinned ~4GB on the ACC message bus + // (incident 2026-08-14, spec 2026-08-14-replay-buffer-byte-budget.md). Trade-off: heavy bulk + // writes shrink the resume window in wall-clock time, making a secondary re-sync more likely - + // deliberate (availability over resumability). driver.setChangeStreamHistoryLimit(REPLICATION_REPLAY_BUFFER_EVENTS); + driver.setChangeStreamHistoryByteBudget(REPLICATION_REPLAY_BUFFER_BYTES); } /** Primary replay-buffer bound (events) backing replication resume-after-disconnect. */ static final int REPLICATION_REPLAY_BUFFER_EVENTS = 100_000; + /** Default replay-buffer byte budget (estimated bytes) - overridable via --replay-buffer. */ + static final long REPLICATION_REPLAY_BUFFER_BYTES = 256L * 1024 * 1024; + + /** + * Replay-buffer byte budget (estimated bytes, 0 = off) - see + * InMemoryDriver.setChangeStreamHistoryByteBudget. Evicting for bytes has the same + * window-lost semantics as count overflow: an affected secondary re-syncs. + */ + public void setReplayBufferByteBudget(long bytes) { + driver.setChangeStreamHistoryByteBudget(bytes); + } + /** * Warn/reject memory watermarks in percent of max heap (100 disables a stage) - see * InMemoryDriver.setMemoryWatermarks. Above the reject watermark, document-creating @@ -673,17 +722,36 @@ public void configureReplicaSet(String name, List hostList, Map + electionManager.updateLogIndex(index, electionManager.getCurrentTerm())); + } try { newReplicationManager.start(); replicationManager = newReplicationManager; @@ -884,9 +1000,12 @@ private synchronized void scheduleReplicationLivenessProbe(String leaderId, Repl *
  • {@code replicationManager == probedManager} - the RM this probe was scheduled for is * still the one assigned (not replaced by a newer leadership/discovery transition, and * not already torn down); and
  • - *
  • {@code !probedManager.isWatchLive()} - the change-stream watch never registered with - * the primary, which (per ReplicationManager's watch-first design) is the reliable - * "never actually connected" signal. + *
  • {@code !probedManager.hasWatchEverRegistered()} - the change-stream watch never + * registered with the primary at any point since start, which (per + * ReplicationManager's watch-first design) is the reliable "never actually connected" + * signal. Deliberately NOT the instantaneous {@code isWatchLive()}: that flag drops + * between every two watch sessions, so sampling it during a routine reconnect gap + * would tear down a connection that did come up. * * On all-true, tears the dead RM down, resets {@code primaryHost} (same reasoning as * {@link #handleReplicationStartFailure}: a re-discovery of the same leader must not be @@ -901,8 +1020,9 @@ synchronized void probeReplicationLiveness(String leaderId, ReplicationManager p if (replicationManager != probedManager) { return; // superseded by a newer ReplicationManager (or already torn down) - stale probe } - if (probedManager.isWatchLive()) { - return; // healthy: the watch registered, this node is actually replicating + if (probedManager.hasWatchEverRegistered()) { + return; // healthy: the watch registered (at least once) - the connection came up; + // any later watch drop is the watch-retry loop's job, not the probe's } log.warn("Replication to {} never became live - tearing down and retrying", leaderId); @@ -1383,7 +1503,17 @@ private void waitForElectionResult() { // Check if we became leader or found one if (electionManager.isLeader()) { - primary = true; + // Mirror write, not a transition: it must not bump the epoch, and it must not + // be able to overwrite a newer callback-driven flip - so re-check leadership + // under the same lock applyLeadershipFlip uses. If a stepdown snuck in between + // the poll above and here, isLeader() is already false (ElectionManager flips + // its state before dispatching the callback) and we skip; if the stepdown + // callback is still queued, its own locked flip runs after ours and wins. + synchronized (leadershipFlagLock) { + if (electionManager.isLeader()) { + primary = true; + } + } primaryHost = host + ":" + port; log.info("Election complete: this node is the leader"); break; @@ -1444,7 +1574,12 @@ private void startReplication() { } } - private void stopReplication() { + // Synchronized so it serializes with startReplicationToLeader on the PoppyDB monitor: a + // discovery callback mid-install either finishes first (and this teardown catches its fresh + // ReplicationManager), or arrives later (and its running-guard no-ops). Without this, a + // callback already past shutdown()'s running=false flip could install an RM that nothing + // ever stops. + private synchronized void stopReplication() { if (replicationManager != null) { replicationManager.stop(); replicationManager = null; @@ -1562,6 +1697,62 @@ ReplicationManager getReplicationManagerForTest() { return replicationManager; } + /** + * The sequence to carry into a replacement {@link ReplicationManager} being started in + * {@link #startReplicationToLeader(String, long)}: the (already-stopped, but still readable) + * predecessor's own final position if one was actually stopped this attempt, otherwise the + * durable {@link #lastKnownAppliedSequence} watermark left behind by a previous attempt - + * which is exactly what a failed-start retry (predecessor {@code null}, since + * {@code replicationManager} was already nulled and no live RM survived to hand a value + * forward) falls back to instead of silently losing the position and reading a vacuous 0. + * + *

    Deliberately pure (reads but never writes {@link #lastKnownAppliedSequence} - the + * caller persists the result separately) and package-private: lets a test exercise the + * fallback decision in isolation - including the {@code predecessor == null} branch that in + * production only a failed {@code newReplicationManager.start()} retry ever reaches - without + * needing to force a real synchronous {@code start()} throw (which in this driver stack + * realistically requires an auth/TLS connect mismatch; disproportionate machinery for + * covering this one fallback decision - see {@code carryOverSequenceFallsBackToPersistedWatermarkWhenNoPredecessor} + * in {@code ReplicationFailClosedTest}). + */ + long carryOverSequenceFor(ReplicationManager predecessor) { + return predecessor != null ? predecessor.getLastAppliedSequence() : lastKnownAppliedSequence; + } + + /** + * Companion to {@link #carryOverSequenceFor(ReplicationManager)} (2026-08-14 production-CI + * fix, I-2): the {@code "host:port"} the sequence returned by that method was actually earned + * against - a live predecessor's own {@link ReplicationManager#getLeaderAddress()}, or the + * durable {@link #lastKnownAppliedSequenceSource} watermark on a failed-start retry, mirroring + * {@code carryOverSequenceFor}'s own fallback exactly (same {@code predecessor} parameter, + * same null-means-fallback shape) so the two are always read as a matched pair. Passed + * together into {@link ReplicationManager#carryOverLastAppliedSequence(long, String)}, whose + * javadoc explains why the sequence is meaningless without this. + */ + String carryOverSourceFor(ReplicationManager predecessor) { + return predecessor != null ? predecessor.getLeaderAddress() : lastKnownAppliedSequenceSource; + } + + /** Test hook: read the durable carry-over watermark (see {@link #lastKnownAppliedSequence}'s javadoc). */ + long getLastKnownAppliedSequenceForTest() { + return lastKnownAppliedSequence; + } + + /** Test hook: seed the durable carry-over watermark without going through a real replication attempt. */ + void setLastKnownAppliedSequenceForTest(long sequence) { + lastKnownAppliedSequence = sequence; + } + + /** Test hook: read the durable carry-over source watermark (see its field javadoc). */ + String getLastKnownAppliedSequenceSourceForTest() { + return lastKnownAppliedSequenceSource; + } + + /** Test hook: seed the durable carry-over source watermark without a real replication attempt. */ + void setLastKnownAppliedSequenceSourceForTest(String source) { + lastKnownAppliedSequenceSource = source; + } + public ElectionManager getElectionManager() { return electionManager; } diff --git a/poppydb/src/main/java/de/caluga/poppydb/PoppyDBCLI.java b/poppydb/src/main/java/de/caluga/poppydb/PoppyDBCLI.java index dc091c620..1bc344e56 100644 --- a/poppydb/src/main/java/de/caluga/poppydb/PoppyDBCLI.java +++ b/poppydb/src/main/java/de/caluga/poppydb/PoppyDBCLI.java @@ -300,6 +300,12 @@ static ServerOptions parse(String[] effectiveArgs, int configTokenCount) { idx += 2; break; + case "--replay-buffer": + opts.replayBuffer = value(effectiveArgs, idx); + opts.sources.put("replay-buffer", src); + idx += 2; + break; + case "--log-level": opts.logLevel = value(effectiveArgs, idx); opts.sources.put("log-level", src); @@ -484,6 +490,18 @@ static PoppyDB buildServer(ServerOptions opts) throws Exception { srv.setMemoryWatermarks(opts.memoryWarnPct, opts.memoryRejectPct); srv.setMaxBsonObjectSize(opts.maxBsonSizeBytes); + long replayBufferBytes; + + try { + replayBufferBytes = opts.replayBufferBytes(); + } catch (IllegalArgumentException e) { + throw new ConfigException(e.getMessage(), e); + } + + srv.setReplayBufferByteBudget(replayBufferBytes); + log.info("Replay buffer byte budget: {} ({} bytes{})", opts.replayBuffer, replayBufferBytes, + replayBufferBytes == 0 ? ", byte cap off" : ""); + // Configure replica set - election is always enabled for multi-node replica sets boolean enableElection = !opts.rsName.isEmpty() && hosts.size() > 1; if (enableElection) { @@ -593,6 +611,9 @@ private static void printHelp() { System.out.println(" -b, --bind : Host to bind to (default: localhost)"); System.out.println(" --log-level : Log verbosity: ERROR, WARN, INFO, DEBUG, TRACE (default: INFO)"); System.out.println(" --memory-warn : Log a warning when heap occupancy crosses this percentage (default: 75, 100 = off)"); + System.out.println(" --replay-buffer : Byte budget for the change-stream replay buffer backing replication resume."); + System.out.println(" Fixed size with k/m/g suffix (e.g. 512m, 1g) or percent of max heap (e.g. 5%),"); + System.out.println(" 0 = byte cap off (default: 256m; the 100000-event count limit always applies)"); System.out.println(" --memory-reject : Reject document-creating writes (code 146 ExceededMemoryLimit) above this"); System.out.println(" heap percentage; updates/deletes/TTL keep working (default: 90, 100 = off)"); System.out.println(" --max-bson-size : BSON document size limit, enforced like mongod (code 10334 BSONObjectTooLarge,"); diff --git a/poppydb/src/main/java/de/caluga/poppydb/ReplicationManager.java b/poppydb/src/main/java/de/caluga/poppydb/ReplicationManager.java index e67b03a62..678a9d9fb 100644 --- a/poppydb/src/main/java/de/caluga/poppydb/ReplicationManager.java +++ b/poppydb/src/main/java/de/caluga/poppydb/ReplicationManager.java @@ -59,6 +59,19 @@ public class ReplicationManager { // Number of times the primary signalled "resume window lost" and we fell back to a full re-sync. // Exposed for tests/metrics to distinguish a clean resume (0) from a re-sync fallback. private final AtomicLong resyncCount = new AtomicLong(0); + // True while the initial-sync retry loop is refusing a destructive full re-sync (clear + + // snapshot, or a shortcut-driven equivalent) because the primary's reported sequence at the + // most recent watch registration is BEHIND the sequence our local data was last known to + // reflect - see the guard in startInitialSyncOnce(). Cleared as soon as an attempt's primary + // sequence catches back up (>= local), whether that attempt then takes the shortcut or a full + // sync. Exposed via getStats() so operators/tests can see a node that is deliberately holding + // onto its data rather than idly "still syncing". + private final AtomicBoolean refusingDestructiveResync = new AtomicBoolean(false); + // Number of times a destructive full re-sync was refused for the reason above. Monotonic + // counter, never reset - distinguishes "never needed to refuse" from "refused N times" in + // stats/tests, independent of the current (possibly already-cleared) refusingDestructiveResync + // flag. + private final AtomicLong refusedResyncCount = new AtomicLong(0); // Wall-clock time (System.currentTimeMillis()) of the previous resync, used to detect resyncs // repeating faster than the buffer can absorb (see triggerResync()). 0 = no resync yet. private final AtomicLong lastResyncTimestamp = new AtomicLong(0); @@ -207,7 +220,11 @@ void armTestPauseInShortcutForTest() { // (via put()) instead of buffering replication events until OOM. private static final int EVENT_QUEUE_CAPACITY = 100_000; private final BlockingQueue> eventQueue = new LinkedBlockingQueue<>(EVENT_QUEUE_CAPACITY); - private ScheduledExecutorService batchProcessor; + // volatile: written by start()/stop(), read by the watch-callback thread in + // requestFlush() with no happens-before edge between them + private volatile ScheduledExecutorService batchProcessor; + /** at most one on-demand flush queued at a time - see requestFlush() */ + private final java.util.concurrent.atomic.AtomicBoolean flushPending = new java.util.concurrent.atomic.AtomicBoolean(); // Flag to enable immediate progress reporting after each batch private volatile boolean immediateProgressReporting = true; @@ -216,6 +233,15 @@ void armTestPauseInShortcutForTest() { private final AtomicLong lastWatchResponseTime = new AtomicLong(0); private static final long STALENESS_THRESHOLD_MS = 30000; // 30 seconds without response = stale + // How long isContinued() sleeps before ending the watch while refusingDestructiveResync is + // true (2026-08-14 task-3 review fix). Paces the register/teardown cycle that refreshes + // lastKnownPrimarySequence - see the pacing comment at that isContinued() check for why this + // is load-bearing, not cosmetic. A fixed interval rather than mirroring the initial-sync + // thread's own growing 1s->30s backoff: that state lives on a different thread and this is a + // different loop (the watch's own getMore cadence, not the sync-decision retry cadence) - + // a fixed value in the same 1-5s ballpark is simpler and avoids coupling the two. + private static final long REFUSAL_WATCH_PACE_MS = 2000; + // Callback to notify when log index is updated (for election consistency) private java.util.function.BiConsumer onLogIndexUpdate; @@ -334,16 +360,53 @@ private void periodicIndexSync() { * Start the batch processor that efficiently applies change events. */ private void startBatchProcessor() { + // A task accepted by execute() but discarded by a later shutdownNow() would leave the + // flag stuck true, silently disabling every on-demand flush for this instance - the + // regression this whole mechanism exists to prevent, and invisible except in latency. + flushPending.set(false); batchProcessor = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "PoppyDB-BatchProcessor"); t.setDaemon(true); return t; }); + // The fixed schedule stays as the safety net (catches anything enqueued while the gate + // was still closed, or a missed wake-up); requestFlush() is what makes a single write + // replicate immediately rather than on the next tick. batchProcessor.scheduleAtFixedRate(this::processBatch, BATCH_FLUSH_INTERVAL_MS, BATCH_FLUSH_INTERVAL_MS, TimeUnit.MILLISECONDS); } + /** + * Asks the batch processor to run now. Submitted to the SAME single-threaded executor the + * periodic flush uses, so an on-demand run can never overlap a scheduled one - {@code + * processBatch()} keeps its single-threaded contract without any locking of its own. + * + *

    {@code flushPending} collapses a burst into one extra run: while a flush is queued or + * in flight, further events do not pile up additional tasks. The flag is cleared BEFORE + * {@code processBatch()} runs, so an event arriving during that run schedules the next one + * and nothing is left sitting in the queue until the timer comes round. + */ + private void requestFlush() { + ScheduledExecutorService bp = batchProcessor; + + if (bp == null || bp.isShutdown() || !applying.get()) { + return; + } + + if (flushPending.compareAndSet(false, true)) { + try { + bp.execute(() -> { + flushPending.set(false); + processBatch(); + }); + } catch (RejectedExecutionException e) { + // shutting down - the periodic task (if any) or the next start handles it + flushPending.set(false); + } + } + } + /** * Process queued events in batches for better performance. */ @@ -711,8 +774,21 @@ public void stop() { // Stop batch processor first to flush remaining events if (batchProcessor != null) { - // Process any remaining events - processBatch(); + // Flush the remainder ON the batch thread, not on the caller's. Calling + // processBatch() directly here raced a concurrently running scheduled (or + // on-demand) flush: two interleaved drainTo() calls can apply events out of + // order, and it is the one place that broke processBatch()'s single-threaded + // contract. Submitting it keeps every invocation on the same thread; if the + // executor is already gone or the flush does not finish in time, shutdownNow() + // below takes over exactly as before. + try { + batchProcessor.submit(this::processBatch).get(1, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + log.debug("Final replication flush did not complete before shutdown: {}", e.toString()); + } + batchProcessor.shutdownNow(); try { batchProcessor.awaitTermination(1, TimeUnit.SECONDS); @@ -969,14 +1045,55 @@ private void startInitialSyncOnce() { } if (!shortcut) { + // Fail-closed destructive-resync guard (D2, 2026-08-14 empty-node-wipe + // fix): a legitimate primary NEVER regresses its own change-stream + // sequence counter - not even a real, replicated dropDatabase, which is + // itself an event and therefore ADVANCES the counter. A primary whose + // sequence at THIS watch registration is BEHIND the sequence our local + // data was last known to reflect can therefore only be a freshly + // restarted/stale process that reset its counter to 0 (or an older + // build's "resume window lost" chain that lost the original data's + // provenance) - not a trustworthy source of "the real current state". + // Wiping local data to match it would be the exact kill chain this fix + // closes: a restarted, empty node winning re-election (or simply coming + // back up on the same address) and every follower dropping its real + // data to match it. Refuse instead: keep the data, keep retrying with + // backoff - a later, genuinely caught-up primary (sequence >= ours) + // un-sticks this on its own, no manual intervention needed. + long primarySeqAtRegistration = lastKnownPrimarySequence.get(); + long localSeqBeforeWipe = lastAppliedSequence.get(); + + if (primarySeqAtRegistration < localSeqBeforeWipe) { + log.error("refusing full re-sync: primary sequence {} is behind local {} - " + + "possible restarted/stale primary, keeping local data", + primarySeqAtRegistration, localSeqBeforeWipe); + refusingDestructiveResync.set(true); + refusedResyncCount.incrementAndGet(); + Thread.sleep(backoffMs); + backoffMs = Math.min(backoffMs * 2, 30_000); + continue; + } + + refusingDestructiveResync.set(false); + // Start each attempt from a clean local slate so a retry after a // partially-successful copy doesn't fail on already-copied documents. // The flag is set BEFORE the clear: even a clear that throws partway // leaves the local state partially wiped, and a later retry must not // run the consistency shortcut against that. wipedThisSyncCycle.set(true); - clearLocalDatabases(); - performInitialSync(); + // Initial-sync writes are never observable via the local change + // stream (MongoDB: initial sync is not oplogged). Without this, the + // wipe below is broadcast as live "drop" events - and during a + // leadership transition the other nodes' still-running OLD + // ReplicationManagers (watching this demoted ex-primary) apply those + // drops to their own data, destroying admin.system.users + // cluster-wide (the StepdownReplicationTest flake: even the freshly + // promoted primary applied the demoted node's wipe-drop). + try (var ignored = localDriver.suppressChangeStreamEvents()) { + clearLocalDatabases(); + performInitialSync(); + } } // Guard: if the watch died or was re-established during the copy (or the @@ -992,6 +1109,55 @@ private void startInitialSyncOnce() { continue; } + // Not (or no longer) refusing: this attempt is about to declare success, + // whether via the shortcut or a full copy, both of which require the guard + // above to have passed (or never triggered - shortcut skips it entirely, + // but a matching dbHash on non-trivial data is itself strong evidence of a + // legitimate, caught-up primary). + refusingDestructiveResync.set(false); + + // Adopt this attempt's confirmed primary sequence as our new base now that + // we are declaring success (I-1, 2026-08-14 final review fix). A plain + // set(), NOT Math.max(current, ...): change-stream sequences are + // PRIMARY-LOCAL (see tryConsistencyShortcut's own javadoc on this) - the + // OLD lastAppliedSequence (from whatever primary we last successfully + // tracked, possibly a dead one with a much HIGHER counter than this brand + // new/still-quiet primary) lives in a completely different, incomparable + // number space from THIS primary's. Taking the max of two unrelated + // counters is not "the safer of two options", it is meaningless - and + // concretely harmful: it left this node believing it needed to resume + // after a sequence number the new primary's own history could never + // contain, so the very next reconnect always hit "resume window lost" -> + // a dbHash mismatch (as soon as one real write happened) -> the D2 guard + // above comparing the new primary's still-low counter against that stale + // inherited high-water mark -> refusing an entirely LEGITIMATE resync, + // unbounded on a quiet cluster (the new primary would need N more writes + // before its counter ever caught up to the old primary's abandoned one). + // "Having successfully synced against THIS primary, its base is my base." + // + // Two compositions this set() must not break, both verified safe: + // + // (1) The election feed just below must not regress. It doesn't: + // ElectionManager#updateLogIndex is ITSELF monotonic-max internally + // (`if (index >= lastLogIndex.get())`, a lower index is silently a no-op) + // - so adopting a LOWER base here can at most make the value THIS method + // reports go down, never the election's own recorded lastLogIndex. The + // monotonic guarantee Task 1 relies on lives in ElectionManager, by + // design, precisely so a primary-local counter reset on THIS side can + // never regress it - see updateLogIndex's own javadoc. + // + // (2) Events buffered during the sync window are not lost. Every event + // sitting in eventQueue right now was captured by the watch AFTER this + // same registration (recordPrimarySequenceAtRegistration ran, and hence + // lastKnownPrimarySequence was captured, at the START of this sync cycle - + // strictly before any of those events could have arrived), so every + // buffered event's own sequence number is >= lastKnownPrimarySequence. + // Setting lastAppliedSequence to that lower bound now and then draining + // the gate is safe: applyChangeEvent/applyBulkInserts advance it further + // via their own per-event Math.max as each buffered (and all subsequent + // live) event is applied - nothing regresses, nothing is skipped. + lastAppliedSequence.set(lastKnownPrimarySequence.get()); + // Success: open the gate. The batch processor now drains the events // buffered during the snapshot (idempotent replay) and all subsequent live // events, in order. @@ -999,6 +1165,25 @@ private void startInitialSyncOnce() { applying.set(true); initialSyncComplete.set(true); initialSyncLatch.countDown(); + + // Seed the election layer's view of our replication position now that we + // hold the primary's dataset (either path: full snapshot or consistency + // shortcut both land here). lastAppliedSequence is already correct at this + // point (either seeded at registration when it started at 0, or reseeded + // just above when it did not) - see the reseed comment above for the full + // picture. Without this, a freshly-synced node that then applies zero LIVE + // events would never reach + // processBatch()'s onLogIndexUpdate call (it only fires when there is + // something in eventQueue to drain) and would keep reporting index 0 to + // ElectionManager despite actually holding real data - wrongly granting + // votes to genuinely empty candidates as voter, and wrongly denied as + // candidate. updateLogIndex()'s monotonic (max) semantics make this safe to + // call unconditionally: it can only raise ElectionManager's view, never + // regress it. + long syncedSeq = lastAppliedSequence.get(); + if (onLogIndexUpdate != null && syncedSeq > 0) { + onLogIndexUpdate.accept(syncedSeq, 0L); + } return; } catch (Exception e) { // Snapshot failed while the watch may still be healthy. Retry from within @@ -1049,6 +1234,20 @@ boolean isWatchLive() { return watchLive.get(); } + /** + * True once the change-stream watch has registered with the primary AT LEAST ONCE since + * {@link #start()} ({@code watchGeneration} only ever advances, one bump per registration). + * This - not the instantaneous {@link #isWatchLive()} - is what PoppyDB's one-shot + * post-start liveness probe must check: {@code watchLive} deliberately drops to false in + * the watch loop's finally block between every two watch sessions, so a probe sampling + * {@code isWatchLive()} during such a routine reconnect gap would tear down a + * ReplicationManager whose connection DID come up (2026-08-06 review finding). A watch that + * registered once and later died is the watch-retry loop's job to repair, not the probe's. + */ + boolean hasWatchEverRegistered() { + return watchGeneration.get() > 0; + } + /** * True when the initial-sync retry loop should attempt the consistency shortcut for the * current iteration; false once {@link #wipedThisSyncCycle} has been set by a @@ -1570,6 +1769,14 @@ public void incomingData(Map data, long cursorId) { // rather than dropping events or growing without bound. try { eventQueue.put(data); + // Apply it now instead of waiting out the flush tick. Without this the + // batch processor only ran on its fixed BATCH_FLUSH_INTERVAL_MS + // schedule, so a single write that a write concern waits on paid the + // full interval - measured in-process (no network): 5.04 ms per + // individual store() against a 3-node replica set vs 0.31 ms against a + // single node, with p50 landing exactly on the 5 ms tick. Batched + // writes never showed it because one tick covers a whole batch. + requestFlush(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); log.warn("Interrupted while enqueuing replication event; dropping event"); @@ -1589,6 +1796,39 @@ public boolean isContinued() { now - lastResponse); return false; } + // While refusing a destructive resync (see the guard in + // startInitialSyncOnce()), recordPrimarySequenceAtRegistration() only ever + // refreshes lastKnownPrimarySequence at watch REGISTRATION - a live watch + // session registers exactly once, so without this, a refusal would freeze + // on the primary sequence observed at that one registration forever, never + // discovering that the primary has since caught up ("a later caught-up + // leader syncs normally" would then require some UNRELATED event, e.g. a + // real disconnect, to ever re-check). Ending the watch here lets the + // replication loop's own retry re-establish it, which re-registers and + // refreshes the primary-sequence signal the destructive-resync guard reads + // on its next attempt. + // + // PACING (2026-08-14 task-3 review fix): this is NOT reached only after a + // maxTimeMS getMore wait as the earlier version of this comment assumed - + // isContinued() is also checked immediately after the very first reply that + // establishes the watch cursor (SingleMongoConnection.watch()'s post- + // establishment check, before any getMore is ever issued), and + // replicationLoop() calls watchForChanges() again with no sleep of its own + // once it returns. Without an explicit sleep here those two facts combine + // into an unbounded register/teardown spin against a possibly-troubled + // primary - measured at ~1400 registrations/s in review, not the "~500ms, + // bounded, self-limiting" cadence this comment used to (wrongly) claim. The + // sleep paces every refusal retry, not just conceptually the first. + if (refusingDestructiveResync.get()) { + log.debug("Watch cycling while refusing a destructive resync, to refresh the " + + "primary-sequence signal"); + try { + Thread.sleep(REFUSAL_WATCH_PACE_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return false; + } return true; } }); @@ -1692,10 +1932,25 @@ private boolean isResumeWindowLost(MorphiumDriverException e) { /** * Fall back to a full re-initial-sync after the primary signalled that our resume point is no * longer replayable. Rearms the Task 8 initial-sync machinery: closes the apply gate, resets the - * sync flags so {@link #startInitialSyncOnce()} launches a fresh snapshot, drops the events left - * over from the lost window, and resets the sequence so the next watch starts fresh (no - * resumeAfter) instead of re-requesting the same lost window in a loop. The replication loop then - * re-runs initial sync + watch on its next iteration. + * sync flags so {@link #startInitialSyncOnce()} launches a fresh snapshot, and drops the events + * left over from the lost window. The replication loop then re-runs initial sync + watch on its + * next iteration. + * + *

    Deliberately does NOT reset {@code lastAppliedSequence} to 0 (unlike before the 2026-08-14 + * empty-node-wipe fix). {@code initialSyncComplete} is already false at this point, which alone + * already suppresses the next watch's {@code resumeAfter} (see the {@code initialSyncComplete.get() + * && resumeSeq > 0} guard in {@link #watchForChanges()}) - zeroing the sequence was never load- + * bearing for that. It WAS, however, load-bearing for a hazard: zeroing it here made + * {@code recordPrimarySequenceAtRegistration()}'s reseed ({@code compareAndSet(0, primarySeq)}) + * fire unconditionally on the very next registration, silently replacing our real local data's + * last-known-good sequence with whatever the new/possibly-empty primary reports - which is + * exactly what let {@link #startInitialSyncOnce()}'s destructive-resync guard be defeated: by the + * time that guard ran, the honest "how far behind is this primary" signal was already gone. + * Preserving the value here is what lets that guard compare the primary's regressed sequence + * against our data's true position instead of a freshly-overwritten 0. The now-stale value is + * reseeded explicitly, and correctly, once a sync attempt actually succeeds (or is legitimately + * allowed to proceed) - see the reseed at the "not (or no longer) refusing" point in + * {@link #startInitialSyncOnce()}. */ private void triggerResync(long fromSequence) { long n = resyncCount.incrementAndGet(); @@ -1712,7 +1967,6 @@ private void triggerResync(long fromSequence) { initialSyncComplete.set(false); initialSyncStarted.set(false); // allow startInitialSyncOnce() to launch a new snapshot watchLive.set(false); - lastAppliedSequence.set(0); // resume fresh; next watch sends no resumeAfter lastReportedSequence.set(0); eventQueue.clear(); // discard events buffered for the lost window } @@ -1738,6 +1992,20 @@ long getResyncCount() { return resyncCount.get(); } + /** + * True while this node is currently refusing a destructive full re-sync because the primary's + * sequence regressed below our local data's (see {@link #getStats()}'s + * {@code refusingDestructiveResync}). + */ + boolean isRefusingDestructiveResync() { + return refusingDestructiveResync.get(); + } + + /** Lifetime count of destructive-resync refusals (see {@link #isRefusingDestructiveResync()}). */ + long getRefusedResyncCount() { + return refusedResyncCount.get(); + } + /** * True when the most recently completed initial sync was satisfied by the consistency * shortcut (local data already matched the primary per dbHash - no clear, no snapshot) @@ -2002,6 +2270,12 @@ public boolean isInitialSyncComplete() { * of MongoDB's RECOVERING member: it must not serve data-plane reads or writes. Returns false * once the initial sync has completed and the local database is a consistent replica, and false * after {@link #stop()} (running == false). + * + *

    Also true while {@link #isRefusingDestructiveResync()} holds - a node refusing a + * destructive resync has NOT re-completed initial sync against the (currently untrusted) primary, + * even though, unlike the ordinary half-cleared case this javadoc otherwise describes, its local + * database is fully intact and deliberately left untouched. It is still treated as RECOVERING + * here (conservative: correctness over availability) rather than carved out as a distinct state. */ public boolean isSyncing() { return running.get() && !initialSyncComplete.get(); @@ -2029,6 +2303,12 @@ public Map getStats() { stats.put("lastReportedSequence", lastReportedSequence.get()); stats.put("lastKnownPrimarySequence", lastKnownPrimarySequence.get()); stats.put("resyncCount", resyncCount.get()); + // D2 (2026-08-14 empty-node-wipe fix): true while this node is deliberately refusing a + // destructive full re-sync because the primary's sequence regressed below our local data's + // - see the guard in startInitialSyncOnce(). refusedResyncCount is the monotonic lifetime + // count of such refusals, independent of whether the flag is currently set. + stats.put("refusingDestructiveResync", refusingDestructiveResync.get()); + stats.put("refusedResyncCount", refusedResyncCount.get()); stats.put("primaryHost", primaryHost + ":" + primaryPort); stats.put("myAddress", myAddress); stats.put("eventQueueSize", eventQueue.size()); @@ -2051,6 +2331,106 @@ public long getLastAppliedSequence() { return lastAppliedSequence.get(); } + /** + * This instance's own replication target, in {@code "host:port"} form - the identity a + * carried sequence must match to be comparable (see the two-arg + * {@link #carryOverLastAppliedSequence(long, String)} overload). {@code primaryHost}/ + * {@code primaryPort} are final, set once at construction and never updated for the life of + * this instance (see the field javadocs) - a leader change always replaces the whole + * {@code ReplicationManager}, it never repoints an existing one. + */ + String getLeaderAddress() { + return primaryHost + ":" + primaryPort; + } + + /** + * Seeds {@link #lastAppliedSequence} from a predecessor {@code ReplicationManager}'s value, + * carried across a leader-change instance replacement (2026-08-14 task-3 review fix, D2 + * defense-in-depth). {@code PoppyDB#startReplicationToLeader} constructs a brand-new + * {@code ReplicationManager} on every leader change; a fresh instance's + * {@code lastAppliedSequence} starts at 0, and + * {@code recordPrimarySequenceAtRegistration()}'s own seed + * ({@code compareAndSet(0, primarySeq)}) then unconditionally adopts whatever the new + * leader reports - making {@code localSeqBeforeWipe == primarySeqAtRegistration} by + * construction and the destructive-resync guard in {@link #startInitialSyncOnce()} vacuously + * pass every time on this path (a primary can never be "behind" a local sequence it just + * supplied itself). Without carrying the predecessor's real position forward, this path was + * protected only by the election-layer empty-vs-data invariant (Tasks 1/2/4), not by this + * task's own guard. + * + *

    Superseded by the two-arg overload below for production use (2026-08-14 + * production-CI fix, I-2): calling this single-arg form unconditionally is only correct when + * the caller has already established that the carried sequence was earned against THIS SAME + * primary - see that overload's javadoc for why blindly carrying a value across a genuine + * leader change caused a real incident (82 refusal loops on poppydb.fritz.box). Kept + * package-private (not deleted) because it is still exactly right for that one case - the + * two-arg overload delegates to it - and because tests exercise it directly to seed a + * {@code ReplicationManager} without a live connection. + * + *

    Must be called before {@link #start()}, while {@code lastAppliedSequence} is still its + * untouched 0 default - enforced with the same {@code compareAndSet(0, ...)} idiom every + * other seed of this field uses (see {@link #recordPrimarySequenceAtRegistration}), so a + * second/late call, or one that races an already-started sync, is a safe no-op rather than a + * regression. A predecessor sequence of 0 (cold-boot / never-synced predecessor, or no + * predecessor at all) is intentionally a no-op - 0 is exactly the legitimate default for a + * genuinely fresh node with nothing to protect. + */ + void carryOverLastAppliedSequence(long predecessorSequence) { + if (predecessorSequence > 0) { + lastAppliedSequence.compareAndSet(0, predecessorSequence); + } + } + + /** + * Primary-identity-aware carry-over (2026-08-14 production-CI fix, I-2): the single-arg + * overload above blindly arms the destructive-resync guard with the predecessor's carried + * sequence, which is only sound when that sequence was earned against THIS SAME primary. + * Change-stream sequences are PRIMARY-LOCAL (see {@code tryConsistencyShortcut}'s own + * javadoc), and a LEADER CHANGE - the very reason a carry-over happens at all - is the NORMAL + * case that makes two RMs' sequence spaces incomparable, not a rare edge case. Production + * evidence (poppydb.fritz.box CI, branch fix/poppydb-empty-node-wipe): after a real leader + * change under messaging load, a follower carried {@code lastAppliedSequence} 227951 from the + * old leader's space; the new leader's own (entirely unrelated) counter was 213896. Every + * reconnect logged "refusing full re-sync: primary sequence 213896 is behind local 227951" + * every 1-2s for 40+ minutes - the node stuck RECOVERING, three messaging test classes timed + * out. The commit that made a successful sync ADOPT the synced primary's base + * ({@code lastAppliedSequence.set(lastKnownPrimarySequence.get())}, see the reseed comment in + * {@link #startInitialSyncOnce()}) could not help: adoption only runs AFTER a successful sync, + * and the guard - armed with the foreign 227951 - was exactly what blocked that sync from ever + * succeeding. Hen-and-egg. + * + *

    {@code predecessorSourceAddress} is the {@code "host:port"} the carried sequence was + * actually earned against (see {@link #getLeaderAddress()}), or {@code null} if there was no + * live predecessor at all. Two cases: + *

      + *
    • Matches this instance's own {@link #getLeaderAddress()} - the true kill chain: + * the SAME node (address-wise) restarted empty/stale, or - the other route into this + * state - the intra-RM {@code triggerResync()} retry path, where the primary literally + * cannot have changed (one {@code ReplicationManager}'s {@code primaryHost}/ + * {@code primaryPort} are final). Arm the guard exactly as before, via + * {@link #carryOverLastAppliedSequence(long)}.
    • + *
    • Any other address, including {@code null} - a genuinely different primary (or + * no predecessor at all). The carried sequence must NOT arm the guard - it lives in an + * unrelated number space. Deliberately a no-op here: {@code lastAppliedSequence} is left + * at its 0 default, so {@code recordPrimarySequenceAtRegistration()}'s EXISTING + * {@code compareAndSet(0, primarySeq)} seed (unconditionally live for every instance, + * not something this method needs to duplicate) adopts THIS primary's own base the + * moment it is first learned at watch registration - "let dbHash/the consistency + * shortcut decide whether a resync is actually needed", exactly as a genuinely fresh + * node would. This is a deliberate scope boundary, not a gap: a wrongly-elected empty + * primary that has itself taken on enough fresh writes could still pass a subsequent + * resync decision - the actual barrier against that is the election-layer invariant + * (Tasks 1/2/4, "an empty node must never win against a data-bearing voter"), not this + * guard.
    • + *
    + */ + void carryOverLastAppliedSequence(long predecessorSequence, String predecessorSourceAddress) { + if (getLeaderAddress().equals(predecessorSourceAddress)) { + carryOverLastAppliedSequence(predecessorSequence); + } + // else: different primary (or no predecessor) - see javadoc; intentionally not armed. + } + /** * The primary's change-stream sequence as observed at the most recent watch registration (see * {@link #recordPrimarySequenceAtRegistration(WatchCommand)}). Updated on every successful diff --git a/poppydb/src/main/java/de/caluga/poppydb/ServerOptions.java b/poppydb/src/main/java/de/caluga/poppydb/ServerOptions.java index 0311330ad..d2121debc 100644 --- a/poppydb/src/main/java/de/caluga/poppydb/ServerOptions.java +++ b/poppydb/src/main/java/de/caluga/poppydb/ServerOptions.java @@ -38,6 +38,11 @@ enum Source { DEFAULT, CONFIG_FILE, CLI } long dumpIntervalSec = 0; int maxConnections = 500; int socketTimeoutSec = 300; + // Replay-buffer byte budget, raw input form (spec: 2026-08-14-replay-buffer-byte-budget.md). + // Suffix k/m/g = fixed bytes, suffix % = percent of max heap (resolved once at startup), + // plain number = bytes, 0 = byte cap off. Kept as the raw string so --print-config can show + // both the input form and the resolved value. + String replayBuffer = "256m"; /** canonical config key (see ConfigLoader) -> origin of the effective value. */ final Map sources = new LinkedHashMap<>(); @@ -105,4 +110,64 @@ Map seedPriorities() { } return prios; } + + /** + * replay-buffer resolved to bytes against the current JVM's max heap. Throws + * IllegalArgumentException with a user-readable message on invalid input - surfaced by + * ConfigInspector.validate() and by buildServer(), same contract as {@link #seedPriorities()}. + */ + long replayBufferBytes() { + return parseReplayBufferBytes(replayBuffer, Runtime.getRuntime().maxMemory()); + } + + /** + * Parses a replay-buffer value: {@code 512m}/{@code 1g}/{@code 64k} = fixed bytes, {@code 5%} + * = percent of {@code maxHeap} (resolved here, the max heap is fixed for the JVM's lifetime), + * a plain number = bytes, {@code 0} = byte cap off. {@code maxHeap} is a parameter so tests + * can resolve percentages deterministically. + */ + static long parseReplayBufferBytes(String input, long maxHeap) { + String v = input == null ? "" : input.trim().toLowerCase(java.util.Locale.ROOT); + + if (v.isEmpty()) { + throw new IllegalArgumentException("replay-buffer must not be empty - use e.g. 256m, 5% or 0 (off)"); + } + + try { + if (v.endsWith("%")) { + double pct = Double.parseDouble(v.substring(0, v.length() - 1).trim()); + + if (pct < 0 || pct > 100) { + throw new IllegalArgumentException("replay-buffer percentage must be between 0 and 100, got: " + input); + } + + return (long) (maxHeap * pct / 100.0); + } + + long factor = 1; + String num = v; + + if (v.endsWith("k")) { + factor = 1024; + num = v.substring(0, v.length() - 1); + } else if (v.endsWith("m")) { + factor = 1024 * 1024; + num = v.substring(0, v.length() - 1); + } else if (v.endsWith("g")) { + factor = 1024L * 1024 * 1024; + num = v.substring(0, v.length() - 1); + } + + long bytes = Long.parseLong(num.trim()) * factor; + + if (bytes < 0) { + throw new IllegalArgumentException("replay-buffer must be >= 0 (0 = off), got: " + input); + } + + return bytes; + } catch (NumberFormatException e) { + throw new IllegalArgumentException("replay-buffer '" + input + + "' is not a valid size - use a byte count with optional k/m/g suffix (e.g. 256m) or a percentage of the max heap (e.g. 5%)"); + } + } } diff --git a/poppydb/src/main/java/de/caluga/poppydb/config/ConfigLoader.java b/poppydb/src/main/java/de/caluga/poppydb/config/ConfigLoader.java index ec04a5d13..f854dab68 100644 --- a/poppydb/src/main/java/de/caluga/poppydb/config/ConfigLoader.java +++ b/poppydb/src/main/java/de/caluga/poppydb/config/ConfigLoader.java @@ -79,6 +79,7 @@ private static void define(String canonical, Type type, String flag, String... a define("memory-warn", Type.INT, "--memory-warn"); define("memory-reject", Type.INT, "--memory-reject"); define("max-bson-size", Type.INT, "--max-bson-size"); + define("replay-buffer", Type.STRING, "--replay-buffer"); define("compressor", Type.COMPRESSOR, "--compressor"); define("rs-name", Type.STRING, "--rs-name"); define("rs-seed", Type.STRING, "--rs-seed"); diff --git a/poppydb/src/main/java/de/caluga/poppydb/election/ElectionManager.java b/poppydb/src/main/java/de/caluga/poppydb/election/ElectionManager.java index e5d576eea..b38031e1a 100644 --- a/poppydb/src/main/java/de/caluga/poppydb/election/ElectionManager.java +++ b/poppydb/src/main/java/de/caluga/poppydb/election/ElectionManager.java @@ -40,6 +40,13 @@ public class ElectionManager { private final AtomicLong lastLogIndex = new AtomicLong(0); private final AtomicLong lastLogTerm = new AtomicLong(0); + // Candidacy restraint (D3, empty-node-wipe fix): highest lastLogIndex this process has ever + // observed reported by ANY peer via AppendEntries/heartbeat traffic - the leader's own index + // (advertised as prevLogIndex while we are a follower) or a follower's matchIndex (while we + // are the leader). Used solely to hold back becomeCandidate() while we are empty; see the + // guard there and its javadoc for the full rationale. + private final AtomicLong highestPeerLogIndexSeen = new AtomicLong(0); + // Election bookkeeping private final Set votesReceived = ConcurrentHashMap.newKeySet(); private volatile long lastHeartbeatTime = 0; @@ -158,6 +165,27 @@ public void stop() { * Transition to FOLLOWER state. */ private void becomeFollower(long term, String leaderId) { + becomeFollower(term, leaderId, true); + } + + /** + * Transition to FOLLOWER state. + * + * @param resetTimer whether to restart the election timer as part of this transition. + * Must be {@code true} for every caller that represents actual contact with a current + * or future leader (a heartbeat, or granting a vote) — that contact is exactly what the + * timer exists to detect, so it's correct to defer our own candidacy further. Must be + * {@code false} for a bare term bump learned from a vote REQUEST we go on to deny (see + * {@link #handleVoteRequest}): otherwise a lower-priority node whose own timeout fires + * first can keep starting new terms every timeout interval, and each of those requests — + * though correctly denied by the priority check below — would still reset a higher- + * priority denier's timer via this method, indefinitely deferring the very candidacy the + * priority check exists to protect. Found via a real 42s election (vs. the ~8s typical + * for this cluster) on poppydb.fritz.box during the 6.3.0 pre-release full suite run: + * the lowest-priority node retried across 4 terms, each retry re-arming the + * second-priority node's timer moments before it would have fired on its own. + */ + private void becomeFollower(long term, String leaderId, boolean resetTimer) { stateLock.lock(); try { ElectionState previousState = state; @@ -205,8 +233,11 @@ private void becomeFollower(long term, String leaderId) { scheduler.execute(() -> onLeadershipChange.accept(false)); } - // Restart election timer - resetElectionTimer(); + // Restart election timer — see the resetTimer javadoc above for why this is + // conditional rather than unconditional. + if (resetTimer) { + resetElectionTimer(); + } } finally { stateLock.unlock(); @@ -238,6 +269,20 @@ private void becomeCandidate() { return; } + // Candidacy restraint (D3, empty-node-wipe fix): we are empty (nothing applied/produced + // this process lifetime) but have observed a peer that holds real data. Starting an + // election now can only lose - handleVoteRequest's isLogAtLeastAsUpToDate check denies + // us on every data-holding voter - while still inflating the term and forcing the + // legitimate leader into a pointless step-down. Hold back until either our own index + // catches up (sync completes - Task 1's seed makes this prompt) or - cold start, no + // data-bearing peer ever observed - there is nothing to defer to. + if (lastLogIndex.get() == 0 && highestPeerLogIndexSeen.get() > 0) { + log.debug("{} holding back candidacy: empty (index=0) but a peer has reported index {} - waiting for sync", + myAddress, highestPeerLogIndexSeen.get()); + resetElectionTimer(); + return; + } + stateLock.lock(); try { // Increment term and vote for self @@ -439,11 +484,15 @@ public VoteResponse handleVoteRequest(VoteRequest request) { log.debug("{} received vote request from {} for term {} (my term={}, candidate priority={}, my priority={})", myAddress, request.getCandidateId(), requestTerm, myTerm, candidatePriority, myPriority); - // If request term is higher, update our term and become follower + // If request term is higher, update our term and become follower. Don't reset our + // own election timer here — this is only a vote REQUEST, not confirmed contact with + // a leader, and we may go on to deny it below (priorityOk). The timer is reset + // further down, but only on the branch where we actually grant the vote — see + // becomeFollower's resetTimer javadoc for why this distinction matters. if (requestTerm > myTerm) { log.info("{} discovered higher term {} from {}, updating from {}", myAddress, requestTerm, request.getCandidateId(), myTerm); - becomeFollower(requestTerm, null); + becomeFollower(requestTerm, null, false); myTerm = currentTerm.get(); } @@ -460,6 +509,14 @@ public VoteResponse handleVoteRequest(VoteRequest request) { // Check if candidate's log is at least as up-to-date as ours boolean logOk = isLogAtLeastAsUpToDate(request.getLastLogTerm(), request.getLastLogIndex()); + if (!logOk) { + // Operator-visible at INFO: this is the exact line that must show up when a + // freshly restarted (empty) node tries to win an election against a node that + // still holds data - see the empty-node-wipe bug this check exists to prevent. + log.info("{} denied vote to {} (candidate log behind: candidateIndex={} < myIndex={})", + myAddress, request.getCandidateId(), request.getLastLogIndex(), lastLogIndex.get()); + } + // Priority-based voting decision: // If we're a higher priority node that can become leader and haven't voted yet, // we should not vote for a lower priority candidate (give ourselves a chance first) @@ -553,22 +610,24 @@ private void checkMajority() { } /** - * Check if candidate's log is at least as up-to-date as ours. - * Per Raft: compare by (lastLogTerm, lastLogIndex) - term is more important. + * Deliberately NOT a Raft §5.4.1 log comparison: {@code ReplicationManager}'s change + * stream sequences are primary-local, and {@code lastLogTerm} is a stand-in fed from + * {@code currentTerm} at uncorrelated moments (leader heartbeat vs. follower batch-apply, + * on different nodes, with no synchronization between the two feeds' timing) - so ordering + * nodes by {@code (term, index)} the way Raft does is not meaningful here: a node that was + * elected once while still empty can carry a high, stale {@code lastLogTerm} at {@code + * index 0}, which a naive term-first comparison would prefer over a real data-holding voter. * - *

    Currently always true in practice: {@code lastLogIndex}/{@code lastLogTerm} are - * never updated by any production caller (see {@link #updateLogIndex}), so both sides of - * every comparison are {@code 0}. This check is dead weight until that is wired up - do not - * rely on it to reject a behind-on-data candidate. + *

    The one invariant this can honestly enforce: a node that has applied/produced no data + * since process start ({@code index 0}) must never win against a voter that has ({@code + * index > 0}) - directly closes the empty-node-wipe bug this method exists for. A stale but + * non-empty candidate (index > 0 but behind) is intentionally NOT denied here; that case + * is handled elsewhere (fail-closed resync refusing sequence regression, and candidacy + * restraint keeping behind nodes from campaigning in the first place - see the other D-tasks + * in this bug's task list). */ private boolean isLogAtLeastAsUpToDate(long candidateLastTerm, long candidateLastIndex) { - long myLastTerm = lastLogTerm.get(); - long myLastIndex = lastLogIndex.get(); - - if (candidateLastTerm != myLastTerm) { - return candidateLastTerm > myLastTerm; - } - return candidateLastIndex >= myLastIndex; + return !(candidateLastIndex == 0 && lastLogIndex.get() > 0); } // ==================== Heartbeat Handling ==================== @@ -599,6 +658,14 @@ private void sendHeartbeats() { return; } + // Keep our own log index fed from real replication progress while we lead - this is + // the leader-side half of the log-recency check's data source (the follower half is + // ReplicationManager's onLogIndexUpdate, wired in PoppyDB). Piggybacked on the existing + // heartbeat cadence rather than a new timer. currentTerm is still passed through as the + // log term for bookkeeping/future use, but isLogAtLeastAsUpToDate no longer reads it - + // see that method's javadoc for why term ordering across nodes isn't meaningful here. + updateLogIndex(localSequenceSupplier.getAsLong(), currentTerm.get()); + AppendEntriesRequest heartbeat = AppendEntriesRequest.heartbeat( currentTerm.get(), myAddress, @@ -656,6 +723,11 @@ public AppendEntriesResponse handleAppendEntries(AppendEntriesRequest request) { lastHeartbeatTime = System.currentTimeMillis(); currentLeader = request.getLeaderId(); + // Candidacy restraint (D3): the leader's own index, advertised as prevLogIndex on + // every heartbeat (see sendHeartbeats), tells us whether the cluster has real data + // even while our own lastLogIndex is still 0. + recordPeerLogIndex(request.getPrevLogIndex()); + // If we were a candidate, step down if (state == ElectionState.CANDIDATE) { log.info("{} stepping down from candidate (received heartbeat from leader {})", @@ -720,6 +792,10 @@ public void handleAppendEntriesResponse(String peer, AppendEntriesResponse respo leaseExpiryTime = System.currentTimeMillis() + config.getLeaderLeaseTimeoutMs(); peerLastContact.put(peer, System.currentTimeMillis()); + // Candidacy restraint (D3): a follower's matchIndex tells us it holds real data, + // relevant if we ever step down and end up empty ourselves (e.g. after a resync). + recordPeerLogIndex(response.getMatchIndex()); + // Nodes older than priority takeover omit the field and report -1 if (response.getPriority() >= 0) { peerPriorities.put(peer, response.getPriority()); @@ -967,28 +1043,88 @@ public boolean isRunning() { } /** - * Update log index/term (called after writes on leader). + * Records the highest lastLogIndex we have observed reported by ANY peer via + * AppendEntries/heartbeat traffic (see {@link #highestPeerLogIndexSeen}). Monotonic (max), + * same rationale as {@link #updateLogIndex}: this is used purely as a "have we ever seen a + * data-bearing peer" signal for the candidacy-restraint guard in {@link #becomeCandidate()}, + * so a peer's index momentarily appearing lower (e.g. it just restarted itself) must not + * make this node newly eligible to race for an election it would still lose against that + * same peer once it resyncs. + */ + private void recordPeerLogIndex(long peerIndex) { + highestPeerLogIndexSeen.updateAndGet(current -> Math.max(current, peerIndex)); + } + + /** + * Update log index/term. Three production callers keep this fed with the real replication + * sequence: + *

      + *
    • Leader: {@link #sendHeartbeats()} calls this every heartbeat with + * {@code localSequenceSupplier}'s current value (wired by PoppyDB to + * {@code driver::getChangeStreamSequence}) and {@code currentTerm} - the same supplier + * already used for priority-takeover catch-up checks.
    • + *
    • Follower, live events: {@code ReplicationManager}'s {@code onLogIndexUpdate} + * hook, wired by PoppyDB in {@code startReplicationToLeader}, calls this after every + * applied batch with {@code lastAppliedSequence} and this node's own {@code + * currentTerm} (substituted for the term {@code ReplicationManager} passes, which it + * has no way to know).
    • + *
    • Follower, initial sync: the same hook is also called once, immediately after + * an initial sync (full snapshot or consistency-shortcut) completes, with the sequence + * seeded at watch registration ({@code recordPrimarySequenceAtRegistration}). Without + * this a freshly-synced node that then applies zero live events would never reach the + * live-event call above and would keep reporting index {@code 0} to this class despite + * holding real data - see that call site's comment in {@code ReplicationManager} for + * the full mechanism.
    • + *
    + * + *

    Term is still passed through as {@code currentTerm} and stored in {@code lastLogTerm} + * (harmless bookkeeping, and may serve a genuine per-log-entry term if PoppyDB ever gets a + * real replicated log), but {@link #isLogAtLeastAsUpToDate} deliberately does NOT read it + * for the vote decision any more - see that method's javadoc. An earlier version of this + * comment argued the two nodes' terms were "the same basis by construction" at comparison + * time; that argument does not hold across the actual comparison, which reads whatever + * {@code lastLogTerm} was last written by this node's own feed (possibly stale, from a term + * this node held before a later election it did not participate in) against the candidate's + * {@code lastLogTerm} (same staleness problem on their side) - i.e. two independently stale + * snapshots, not a fresh pair. Relying on term ordering there reopened the exact bug this + * method exists to close (a once-elected, now-empty node carrying a high stale term at index + * 0 outranking a real data-holding voter). Index-only comparison side-steps this entirely. + * + *

    Monotonic (max) index: {@code index} is only ever raised, never lowered - a call + * with an {@code index} lower than the current value is a no-op. This is required, not just + * defensive: the initial-sync seed above can set a real, non-zero index before this node has + * applied or produced any live event of its own; the leader-side heartbeat feed + * ({@link #sendHeartbeats()}) reads the LOCAL driver's change-stream sequence, which initial + * sync deliberately runs under {@code suppressChangeStreamEvents()} and therefore never + * advances for synced data. Without monotonic semantics, the very next heartbeat after + * becoming leader (or the next call from either feed racing the other) would silently + * overwrite the seeded value back down to {@code 0}, reopening the empty-node-wipe bug for + * exactly the freshly-synced node the seed exists to protect. + * + *

    Thread-safety: called from two independent, unsynchronized threads - the leader's + * heartbeat scheduler ({@link #sendHeartbeats()}) and the follower's replication batch + * processor (via the {@code onLogIndexUpdate} hook wired in PoppyDB). Takes {@code + * stateLock} for the duration of the read-compare-write so it can never interleave with + * itself or with {@link #handleVoteRequest}'s read of both fields (which already runs under + * the same lock). * - *

    Known limitation - currently dead code: no production caller ever invokes this. - * {@code ReplicationManager} does report replication progress via its own - * {@code onLogIndexUpdate} hook, but nothing wires that hook to this method, so - * {@code lastLogIndex}/{@code lastLogTerm} stay {@code 0} on every node for the node's - * entire lifetime. The consequence is in {@link #isLogAtLeastAsUpToDate}: every vote - * request's log comparison is {@code 0 == 0}, i.e. vacuously "at least as up to date" - - * the log check in {@link #handleVoteRequest} can never deny a vote for being behind. A - * node whose local state was just cleared for a resync (e.g. mid-{@code clearLocalDatabases}) - * is therefore exactly as electable as a fully caught-up peer; this is the mechanism behind - * the users-file version gate's documented mid-resync caveat (see - * {@code docs/poppydb.md#bootstrapping-users---users-file}). Pre-existing, not something - * this change fixes - wiring real log tracking through election would need an actual - * replicated log (indices that mean the same thing across a leader change), which - * {@code ReplicationManager}'s per-node change-stream sequence numbers do not provide (see - * {@code ReplicationManager#tryConsistencyShortcut}'s javadoc on why sequences are - * primary-local). Tracked as a follow-up, not silently relied upon. + *

    Because both process state and this in-memory field reset to {@code 0} on restart, a + * node whose local database was just cleared for a resync (e.g. mid-{@code + * clearLocalDatabases}) still starts back at {@code 0} - that is intentional (see the + * users-file version gate's documented mid-resync caveat, + * {@code docs/poppydb.md#bootstrapping-users---users-file}); it is exactly why {@link + * #isLogAtLeastAsUpToDate} now has a real value on the other side to compare against. */ public void updateLogIndex(long index, long term) { - lastLogIndex.set(index); - lastLogTerm.set(term); + stateLock.lock(); + try { + if (index >= lastLogIndex.get()) { + lastLogIndex.set(index); + lastLogTerm.set(term); + } + } finally { + stateLock.unlock(); + } } /** @@ -1026,23 +1162,29 @@ public List getPeerAddresses() { * Only meaningful when we ARE the leader: the leader is the only role * that actively heartbeats every peer and tracks acks, so a follower has no independent * way to know whether some OTHER follower is up - it returns true (optimistic/unknown) in - * that case, and also the first time this is asked about a peer we've never yet heard from - * at all (e.g. right after an election, before the first heartbeat round-trip), so a - * healthy peer is never falsely flagged DOWN by a startup race. Only a peer that WAS - * reachable and has since gone stale is reported unreachable. + * that case. + * + *

    A peer with NO contact entry at all gets a grace period of the same freshness window, + * measured from {@code leaderSince} ({@code becomeLeader()} clears {@code peerLastContact}, + * so every peer starts entry-less on each new leadership). Within the window it is treated + * as reachable, so a healthy peer is never falsely flagged DOWN by the startup race (first + * heartbeat round-trip still in flight). Beyond it, no-entry means the peer has not acked a + * single heartbeat since we became leader - the typical shape of the ex-primary that died + * WITH the failover, which an optimistic-forever null-check would report SECONDARY for the + * rest of this leadership (2026-08-06 review finding). */ public boolean isPeerReachable(String peer) { if (state != ElectionState.LEADER) { return true; } + long freshnessMs = Math.max(3L * config.getHeartbeatIntervalMs(), 2000L); Long lastContact = peerLastContact.get(peer); if (lastContact == null) { - return true; + return System.currentTimeMillis() - leaderSince <= freshnessMs; } - long freshnessMs = Math.max(3L * config.getHeartbeatIntervalMs(), 2000L); return System.currentTimeMillis() - lastContact <= freshnessMs; } diff --git a/poppydb/src/main/java/de/caluga/poppydb/messaging/MessagingOptimizer.java b/poppydb/src/main/java/de/caluga/poppydb/messaging/MessagingOptimizer.java index 1687d03ba..84be317ee 100644 --- a/poppydb/src/main/java/de/caluga/poppydb/messaging/MessagingOptimizer.java +++ b/poppydb/src/main/java/de/caluga/poppydb/messaging/MessagingOptimizer.java @@ -29,11 +29,12 @@ public class MessagingOptimizer { // Key: db.lockCollection -> parent messaging collection key private final ConcurrentHashMap lockCollectionMapping = new ConcurrentHashMap<>(); - // Standard indexes for messaging - field name -> direction (1 or -1) + // Standard indexes for messaging - field name -> direction (1 or -1). + // No locked_by/locked index: those fields no longer exist on Msg (locking moved to the + // separate MsgLock collection), so such an index would only be dead insert overhead. public static final List> MESSAGING_INDEXES = List.of( Doc.of("key", Doc.of("timestamp", 1), "name", "msg_timestamp_1"), Doc.of("key", Doc.of("sender", 1), "name", "msg_sender_1"), - Doc.of("key", Doc.of("locked_by", 1, "locked", 1), "name", "msg_locked_by_1_locked_1"), Doc.of("key", Doc.of("processed_by", 1), "name", "msg_processed_by_1") ); diff --git a/poppydb/src/main/java/de/caluga/poppydb/netty/FindCursorRegistry.java b/poppydb/src/main/java/de/caluga/poppydb/netty/FindCursorRegistry.java index 6fc94cef9..eb3b1e059 100644 --- a/poppydb/src/main/java/de/caluga/poppydb/netty/FindCursorRegistry.java +++ b/poppydb/src/main/java/de/caluga/poppydb/netty/FindCursorRegistry.java @@ -138,6 +138,9 @@ static final class FindCursorState { final Map filter; final Map sort; final Map projection; + // The find's collation - refills must re-execute the query with it, or a getMore + // window would silently match differently than the firstBatch did (#252). + final Map collation; final int batchSize; // true if the original find had a positive (non-zero) limit; caps how many more // documents may ever be pulled in via refills, independent of what's left to match. @@ -152,13 +155,15 @@ static final class FindCursorState { volatile long lastAccessed; FindCursorState(String db, String collection, Map filter, Map sort, - Map projection, List> remaining, int batchSize, + Map projection, Map collation, + List> remaining, int batchSize, int nextSkip, boolean hasLimit, int remainingLimit) { this.db = db; this.collection = collection; this.filter = filter; this.sort = sort; this.projection = projection; + this.collation = collation; this.remaining = remaining; this.batchSize = batchSize; this.nextSkip = nextSkip; diff --git a/poppydb/src/main/java/de/caluga/poppydb/netty/MongoCommandHandler.java b/poppydb/src/main/java/de/caluga/poppydb/netty/MongoCommandHandler.java index ca47d4a5d..f75bc5337 100644 --- a/poppydb/src/main/java/de/caluga/poppydb/netty/MongoCommandHandler.java +++ b/poppydb/src/main/java/de/caluga/poppydb/netty/MongoCommandHandler.java @@ -39,7 +39,11 @@ public class MongoCommandHandler extends ChannelInboundHandlerAdapter { private static final Logger log = LoggerFactory.getLogger(MongoCommandHandler.class); - // Dedicated executor for command processing — offloads work from Netty I/O threads. + // Executor used ONLY for the asynchronous write-concern replication wait (see postWrite) - + // command processing itself, including the insert/find/update/delete fast paths, runs + // synchronously on the Netty event loop. A slow command therefore blocks every other + // connection on the same event loop; moving data commands onto a worker pool (with per- + // channel response ordering) is a known, deliberate open point, not an oversight. // Uses a fixed pool (not virtual threads) to bound memory: virtual threads caused OOM // because hundreds accumulated waiting on InMemoryDriver's per-collection write lock. // Pool size = 2x CPU cores provides enough parallelism without memory pressure. @@ -61,7 +65,7 @@ public class MongoCommandHandler extends ChannelInboundHandlerAdapter { private static final Set WRITE_COMMANDS = Set.of( "insert", "update", "delete", "findandmodify", "createindexes", "create", "drop", "dropindexes", "dropdatabase", "bulkwrite", - "createuser", "updateuser" + "createuser", "updateuser", "dropuser" ); // Control-plane / handshake / session / election commands that are handled with their @@ -104,7 +108,8 @@ public class MongoCommandHandler extends ChannelInboundHandlerAdapter { * Outcome of the shared pre-dispatch middleware. When {@link #errorResponse} is non-null * the caller must send it and stop; otherwise dispatch proceeds. */ - private static final class CheckResult { + // package-private: exercised by SecondaryReadPreferenceTest + static final class CheckResult { static final CheckResult PROCEED = new CheckResult(null); final Map errorResponse; @@ -279,6 +284,20 @@ private void processMessage(ChannelHandlerContext ctx, WireProtocolMessage msg) } } + /** buildInfo.versionArray as mongo-tools et al. expect it: the leading numeric components + * of the version string, zero-padded to 4 entries ("6.3.2-SNAPSHOT" -> [6,3,2,0]). + * mongorestore refuses to talk to a server whose versionArray has fewer than 3 entries. */ + static List buildVersionArray(String version) { + List arr = new java.util.ArrayList<>(4); + for (String part : version.split("[.\\-]")) { + if (!part.matches("\\d+")) break; + arr.add(Integer.parseInt(part)); + if (arr.size() == 4) break; + } + while (arr.size() < 4) arr.add(0); + return arr; + } + private void processOpQuery(ChannelHandlerContext ctx, OpQuery query) throws Exception { Map doc = query.getDoc(); int requestId = query.getMessageId(); @@ -287,15 +306,15 @@ private void processOpQuery(ChannelHandlerContext ctx, OpQuery query) throws Exc // isMaster via OpQuery (legacy) log.debug("OpQuery->isMaster"); OpReply reply = new OpReply(); - reply.setFlags(2); + // AWAIT_CAPABLE like real mongod. QUERY_FAILURE (2) here made strict drivers + // (mongo-tools/Go) read the hello document as an error and drop the connection, + // breaking mongodump/mongorestore; lenient drivers (Node, morphium) ignore flags. + reply.setFlags(OpReply.AWAIT_CAPABLE_FLAG); reply.setMessageId(msgId.incrementAndGet()); reply.setResponseTo(requestId); reply.setNumReturned(1); - Map response = getHelloResult().toMsg(); - response.put("poppyDB", true); - response.put("morphiumServer", true); - response.put("inMemoryBackend", true); + Map response = helloAnswer(); reply.setDocuments(Arrays.asList(response)); ctx.writeAndFlush(reply); @@ -304,7 +323,7 @@ private void processOpQuery(ChannelHandlerContext ctx, OpQuery query) throws Exc // OpQuery is deprecated OpReply reply = new OpReply(); - reply.setFlags(2); + reply.setFlags(OpReply.QUERY_FAILURE_FLAG); reply.setMessageId(msgId.incrementAndGet()); reply.setResponseTo(requestId); reply.setNumReturned(1); @@ -379,6 +398,15 @@ private void processOpMsg(ChannelHandlerContext ctx, OpMsg opMsg) throws Excepti Map doc = opMsg.getFirstDoc(); int requestId = opMsg.getMessageId(); + // Kind-1 document-sequence sections (mongorestore/mongoimport bulk writes; morphium + // clients never send them): per wire spec each sequence is equivalent to an array + // field of the same name in the command body ("documents"/"updates"/"deletes"). + if (opMsg.getDocuments() != null) { + for (var seq : opMsg.getDocuments().entrySet()) { + doc.putIfAbsent(seq.getKey(), seq.getValue()); + } + } + if (log.isDebugEnabled()) log.debug("Incoming {}", Utils.toJsonString(doc)); String cmd = doc.keySet().iterator().next(); // first key = command name (no stream overhead) @@ -446,6 +474,7 @@ private void dispatchOpMsg(ChannelHandlerContext ctx, Map doc, S case "buildInfo": answer = Doc.of("version", InMemoryDriver.REPORTED_SERVER_VERSION, + "versionArray", buildVersionArray(InMemoryDriver.REPORTED_SERVER_VERSION), "buildEnvironment", Doc.of("distarch", "java", "targetarch", "java"), "ok", 1.0); break; @@ -454,10 +483,7 @@ private void dispatchOpMsg(ChannelHandlerContext ctx, Map doc, S case "isMaster": case "hello": log.debug("OpMsg->hello/ismaster"); - answer = getHelloResult().toMsg(); - answer.put("poppyDB", true); - answer.put("morphiumServer", true); - answer.put("inMemoryBackend", true); + answer = helloAnswer(); break; case "getFreeMonitoringStatus": @@ -496,13 +522,11 @@ private void dispatchOpMsg(ChannelHandlerContext ctx, Map doc, S break; case "abortTransaction": - handleAbortTransaction(ctx); - answer = Doc.of("ok", 1.0); + answer = handleAbortTransaction(ctx); break; case "commitTransaction": - handleCommitTransaction(ctx); - answer = Doc.of("ok", 1.0); + answer = handleCommitTransaction(ctx); break; case "getMore": @@ -1028,7 +1052,8 @@ && postWrite(ctx, doc, cmd, answer, requestId)) { * the error response the caller must send. */ @SuppressWarnings("unchecked") - private CheckResult preDispatch(ChannelHandlerContext ctx, String cmd, Map doc) { + // package-private: exercised by SecondaryReadPreferenceTest + CheckResult preDispatch(ChannelHandlerContext ctx, String cmd, Map doc) { boolean isWriteCommand = WRITE_COMMANDS.contains(cmd.toLowerCase()); boolean isPrimary = isCurrentPrimary(); @@ -1065,10 +1090,17 @@ private CheckResult preDispatch(ChannelHandlerContext ctx, String cmd, Map readPref = (Map) doc.get("$readPreference"); - if (readPref != null && "primary".equalsIgnoreCase((String) readPref.get("mode"))) { + if (readPref == null || "primary".equalsIgnoreCase((String) readPref.get("mode"))) { String currentPrimary = getCurrentPrimaryHost(); Map errorResponse = Doc.of( "ok", 0.0, @@ -1097,8 +1129,23 @@ private CheckResult preDispatch(ChannelHandlerContext ctx, String cmd, MapThe replication coordinator is resolved through {@link #replicationCoordinator()} at * call time so a later switch to a live supplier needs no change here. */ - private boolean postWrite(ChannelHandlerContext ctx, Map doc, String cmd, + // package-private: exercised by JournalConcernHonestyTest + boolean postWrite(ChannelHandlerContext ctx, Map doc, String cmd, Map answer, int requestId) { + // PoppyDB has no journal: j:true promises durability that does not exist. Like mongod + // without journaling, the write is executed but the concern fails honestly (code 2, + // BadValue). Checked BEFORE the coordinator/primary guards so it also fires standalone, + // and short-circuits the replication wait - the concern is already unsatisfiable. + Object wc = doc.get("writeConcern"); + if (wc instanceof Map && Boolean.TRUE.equals(((Map) wc).get("j"))) { + answer.put("writeConcernError", Doc.of( + "code", 2, + "codeName", "BadValue", + "errmsg", "cannot use 'j' option: PoppyDB has no journal (in-memory store with snapshot persistence)" + )); + return false; + } + ReplicationCoordinator coordinator = replicationCoordinator(); if (coordinator == null || !isCurrentPrimary()) { return false; @@ -1522,6 +1569,35 @@ private String memberAddress() { return myAddress; } + /** + * The complete hello/isMaster answer: topology from {@link #getHelloResult()} plus the + * PoppyDB identity flags. Single source for both the OP_QUERY legacy path and the OP_MSG + * path, so the two can never drift. + */ + // package-private: exercised by HelloCapabilitiesTest + Map helloAnswer() { + Map answer = getHelloResult().toMsg(); + answer.put("poppyDB", true); + answer.put("morphiumServer", true); + answer.put("inMemoryBackend", true); + // Honest capability advertisement: the hello reply's RS topology + logical sessions + // make modern drivers enable retryable writes by default, but PoppyDB has no + // (lsid, txnNumber) dedup (spec: issue #293) - there is no standard hello field to + // say "sessions yes, retryable writes no", so clients/tooling get an explicit + // document instead of discovering the gaps at runtime. Documented in docs/poppydb.md. + Doc capabilities = Doc.of( + "version", 1, + "retryableWrites", false, + "journal", false, + "durability", "snapshot", + "readConcern", "local", + "transactions", "partial" + ); + capabilities.put("textSearch", "simplified"); + answer.put("poppyCapabilities", capabilities); + return answer; + } + private HelloResult getHelloResult() { HelloResult res = new HelloResult(); res.setHelloOk(true); @@ -1689,30 +1765,49 @@ private void setupTransactionContext(ChannelHandlerContext ctx, Map handleAbortTransaction(ChannelHandlerContext ctx) { MorphiumTransactionContext txCtx = ctx.channel().attr(TX_CONTEXT_KEY).getAndSet(null); if (txCtx != null) { log.debug("Aborting transaction"); - driver.setTransactionContext(txCtx); try { + driver.setTransactionContext(txCtx); driver.abortTransaction(); } catch (Exception e) { log.error("Error aborting transaction", e); + return txnErrorAnswer("abortTransaction", e); } } + return Doc.of("ok", 1.0); } - private void handleCommitTransaction(ChannelHandlerContext ctx) { + // package-private: exercised by TransactionErrorPropagationTest + Map handleCommitTransaction(ChannelHandlerContext ctx) { MorphiumTransactionContext txCtx = ctx.channel().attr(TX_CONTEXT_KEY).getAndSet(null); if (txCtx != null) { log.debug("Committing transaction"); - driver.setTransactionContext(txCtx); try { + driver.setTransactionContext(txCtx); driver.commitTransaction(); } catch (Exception e) { log.error("Error committing transaction", e); + return txnErrorAnswer("commitTransaction", e); } } + return Doc.of("ok", 1.0); + } + + /** + * A commit/abort that threw was previously logged and acknowledged with ok:1 - the client + * believed its transaction was committed. The failure is answered mongo-shaped instead; + * code 8 (UnknownError) unless the driver attached a specific mongo code. + */ + private static Map txnErrorAnswer(String cmd, Exception e) { + Object code = 8; + if (e instanceof MorphiumDriverException mde && mde.getMongoCode() != null) { + code = mde.getMongoCode(); + } + return Doc.of("ok", 0.0, "errmsg", cmd + " failed: " + e.getMessage(), "code", code); } private String extractSessionId(Map doc) { @@ -2056,7 +2151,7 @@ Map processInsertDirect(Map doc) { Map answer = Doc.of("ok", 1.0, "n", docs.size()); if (writeErrors != null && !writeErrors.isEmpty()) { answer.put("writeErrors", writeErrors); - answer.put("n", docs.size() - writeErrors.size()); + answer.put("n", InMemoryDriver.insertedCountFromWriteErrors(docs.size(), ordered, writeErrors)); } return answer; } catch (MorphiumDriverException e) { @@ -2069,12 +2164,17 @@ Map processInsertDirect(Map doc) { } @SuppressWarnings("unchecked") - private Map processFindDirect(ChannelHandlerContext ctx, Map doc, int requestId) { + // package-private: exercised by FastPathOptionsTest + Map processFindDirect(ChannelHandlerContext ctx, Map doc, int requestId) { String db = (String) doc.get("$db"); String coll = (String) doc.get("find"); Map filter = (Map) doc.get("filter"); Map sort = (Map) doc.get("sort"); Map projection = (Map) doc.get("projection"); + // The client's collation was ignored on this path (#252 follow-up) - update/delete/ + // count/distinct were fixed, find was not. hint stays unread: the InMemoryDriver has + // no hint support on any path, so ignoring it cannot diverge from the generic path. + Map collation = (Map) doc.get("collation"); Integer limit = doc.get("limit") instanceof Number ? ((Number) doc.get("limit")).intValue() : 0; Integer skip = doc.get("skip") instanceof Number ? ((Number) doc.get("skip")).intValue() : 0; Integer batchSize = doc.get("batchSize") instanceof Number ? ((Number) doc.get("batchSize")).intValue() : 0; @@ -2094,7 +2194,7 @@ private Map processFindDirect(ChannelHandlerContext ctx, Map 0) fetchLimit = Math.min(fetchLimit, limit); - var window = driver.find(db, coll, filter, sort, projection, skip, fetchLimit); + var window = driver.find(db, coll, filter, sort, projection, collation, skip, fetchLimit); if (window.size() > batchSize) { List> firstBatch = new ArrayList<>(window.subList(0, batchSize)); @@ -2104,7 +2204,7 @@ private Map processFindDirect(ChannelHandlerContext ctx, Map 0; int remainingLimit = hasLimit ? Math.max(0, limit - window.size()) : 0; findCursorRegistry.put(cursorId, new FindCursorRegistry.FindCursorState(db, coll, filter, sort, projection, - retained, batchSize, nextSkip, hasLimit, remainingLimit)); + collation, retained, batchSize, nextSkip, hasLimit, remainingLimit)); channelCursors.add(cursorId); return Doc.of("ok", 1.0, "cursor", Doc.of("firstBatch", firstBatch, "id", cursorId, "ns", db + "." + coll)); @@ -2116,7 +2216,7 @@ private Map processFindDirect(ChannelHandlerContext ctx, Map processFindDirect(ChannelHandlerContext ctx, Map> refill = driver.find(state.db, state.collection, state.filter, - state.sort, state.projection, state.nextSkip, fetchLimit); + state.sort, state.projection, state.collation, state.nextSkip, fetchLimit); state.nextSkip += refill.size(); if (state.hasLimit) state.remainingLimit -= refill.size(); state.remaining.addAll(refill); diff --git a/poppydb/src/main/java/de/caluga/poppydb/netty/MongoWireProtocolDecoder.java b/poppydb/src/main/java/de/caluga/poppydb/netty/MongoWireProtocolDecoder.java index 4fc3c04ed..eaf8539b9 100644 --- a/poppydb/src/main/java/de/caluga/poppydb/netty/MongoWireProtocolDecoder.java +++ b/poppydb/src/main/java/de/caluga/poppydb/netty/MongoWireProtocolDecoder.java @@ -99,9 +99,21 @@ protected void decode(ChannelHandlerContext ctx, ByteBuf in, List out) t log.debug("Decoded {} message, id={}, size={}", code.name(), requestId, messageSize); out.add(message); } catch (Exception e) { - log.error("Failed to parse {} message (requestId={}, size={}): {} — skipping", + log.error("Failed to parse {} message (requestId={}, size={}): {} — rejecting", code.name(), requestId, messageSize, e.getMessage()); - // Bytes already consumed, stream stays in sync — don't close the connection + // Bytes already consumed, stream stays in sync — don't close the connection. + // But DO answer: silently skipping leaves the client waiting for a reply that + // never comes (observed as mongosh/mongorestore hanging forever on a document + // the BSON decoder could not parse). + if (code == WireProtocolMessage.OpCode.OP_MSG) { + de.caluga.morphium.driver.wireprotocol.OpMsg err = new de.caluga.morphium.driver.wireprotocol.OpMsg(); + err.setMessageId(requestId + 1_000_000); + err.setResponseTo(requestId); + err.setFirstDoc(de.caluga.morphium.driver.Doc.of( + "ok", 0.0, "errmsg", "message could not be parsed: " + e.getMessage(), + "code", 22, "codeName", "InvalidBSON")); + ctx.writeAndFlush(err); + } } } diff --git a/poppydb/src/test/java/de/caluga/poppydb/EmptyNodeRestartWipeTest.java b/poppydb/src/test/java/de/caluga/poppydb/EmptyNodeRestartWipeTest.java new file mode 100644 index 000000000..9418eaf94 --- /dev/null +++ b/poppydb/src/test/java/de/caluga/poppydb/EmptyNodeRestartWipeTest.java @@ -0,0 +1,368 @@ +package de.caluga.poppydb; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.MorphiumConfig; +import de.caluga.morphium.driver.Doc; +import de.caluga.poppydb.election.ElectionConfig; +import de.caluga.test.mongo.suite.data.UncachedObject; + +/** + * E2E regression test for the empty-node-restart cluster-wide data-loss bug + * (2026-08-14-poppydb-empty-node-wipe) - pins down the exact real-world repro forever, at the + * full multi-node in-process replica-set level (election + replication wired together, unlike + * {@link ReplicationFailClosedTest} which exercises the replication-only D2 guard in isolation). + * + *

    The bug, as it happened in production: a 3-node replica set (distinct priorities), + * fully replicated. The highest-priority node was killed and restarted EMPTY (fresh process, no + * on-disk/in-memory state, same address). Before the fixes on this branch: + *

      + *
    1. the empty, freshly-restarted node won the election on priority alone, despite having + * zero data (its {@code lastLogIndex} started at 0, but nothing stopped it campaigning or + * being granted votes purely on priority/term);
    2. + *
    3. the data-bearing followers, reconnecting to what was now "the primary", found their + * replicated namespace set didn't match the empty leader's and fell back to a full + * resync - which meant dropping their own (real, correct) databases first: "Falling back + * to full sync: replicated namespace sets differ (primary: {}, local: {...})".
    4. + *
    + * Net effect: an operational restart of a single, already-caught-up node wiped the entire + * cluster's data. + * + *

    The fixes under test (all already on this branch): vote safety + candidacy restraint + * so an empty node cannot win an election while data-bearing peers exist (commits + * d6735e0ee/c8a5cb669, 2ee1848bf), freshly-synced nodes reporting their true replication position + * to the election instead of a stale 0 (47877ad18), and a fail-closed guard on the replication + * side that refuses a destructive resync from a primary whose sequence has regressed relative to + * local state (49633aba6). This test does not target any one of those commits individually - it + * pins the OUTCOME: no matter which layer is doing the protecting, the cluster must survive this + * kill chain with zero data loss. + * + *

    Design notes: + *

      + *
    • "Restart empty" is reproduced literally: the node is hard {@link PoppyDB#shutdown()}, and + * a brand-new {@code PoppyDB} instance - fresh in-memory driver, fresh election state - is + * started on the exact same port, exactly like a process manager restarting a crashed + * server (modeled on {@link ReplicationFailClosedTest}'s "kill primary, start fresh empty + * PoppyDB on the same port" trick, here at the full RS/election level instead of a + * manually-wired {@link ReplicationManager}).
    • + *
    • Every count is read via {@link PoppyDB#getDriver()} directly against each node's own + * in-memory driver, never over the wire - secondaries reject unqualified wire reads by + * design (see {@code b15c28704}), so a wire-level count would silently only ever prove the + * primary's view, not each node's own local state, which is exactly what a wipe would + * corrupt.
    • + *
    • {@link #watchConvergence} is an ACTIVE watch, not a single condition-poll: on every tick + * (150ms) it re-asserts that the still-data-bearing nodes have not lost anything, and that + * the restarted node - if it currently claims leadership - already has the full data set. + * This catches a transient wipe-then-recover as reliably as a permanent one, and catches a + * "won leadership while still empty" violation the instant it happens rather than only if + * it happens to still be true whenever a single poll happens to sample it.
    • + *
    • Priority takeover timers are shortened ({@link #fastTakeoverConfig()}) purely to keep the + * "restarted highest-priority node may reclaim leadership, but only once synced" leg of + * Test A actually exercised within the test's timeout, rather than leaving it as a + * might-or-might-not-happen possibility under the 30s default stability window.
    • + *
    + */ +@Tag("server") +public class EmptyNodeRestartWipeTest { + + private static final Logger log = LoggerFactory.getLogger(EmptyNodeRestartWipeTest.class); + + private static final String DB = "emptynodewipe"; + private static final String COLL = "objs"; + private static final int DOCS = 100; + + /** Started nodes, shut down in reverse start order on teardown. */ + private final List nodes = new ArrayList<>(); + + @AfterEach + public void tearDown() { + for (int i = nodes.size() - 1; i >= 0; i--) { + try { + nodes.get(i).shutdown(); + } catch (Exception ignored) { + } + } + nodes.clear(); + } + + // ---- RS bootstrap helpers (pattern of UserFailoverTest / StepdownReplicationTest) ------- + + private int nextPort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private void startServer(PoppyDB srv, int port) throws Exception { + nodes.add(srv); + srv.start(); + long deadline = System.currentTimeMillis() + 10_000; + while (true) { + try (Socket s = new Socket()) { + s.connect(new InetSocketAddress("localhost", port), 250); + return; + } catch (Exception e) { + if (System.currentTimeMillis() > deadline) { + throw e; + } + Thread.sleep(50); + } + } + } + + private void waitForPrimary(PoppyDB node) throws Exception { + long deadline = System.currentTimeMillis() + 15_000; + while (!node.isPrimary() && System.currentTimeMillis() < deadline) { + Thread.sleep(50); + } + assertTrue(node.isPrimary(), "node must become primary"); + } + + /** Poll a condition with generous timeout - replication/election is asynchronous, never fixed-sleep. */ + private boolean poll(long timeoutMs, Callable condition) throws Exception { + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) { + if (Boolean.TRUE.equals(condition.call())) { + return true; + } + Thread.sleep(100); + } + return Boolean.TRUE.equals(condition.call()); + } + + /** + * A fresh {@link ElectionConfig} instance per call (never share one instance across nodes - + * {@code PoppyDB#configureReplicaSet} mutates its config's priority in place, which would + * silently cross-contaminate every node handed the same object) with shortened priority + * takeover timers, so a caught-up higher-priority node reclaiming leadership is something + * this test can actually observe within its timeout instead of only maybe happening within + * the 30s production default. + */ + private ElectionConfig fastTakeoverConfig() { + return new ElectionConfig() + .setPriorityTakeoverMinStabilityMs(3000) + .setPriorityTakeoverCheckIntervalMs(1000) + .setPriorityTakeoverStepDownSecs(3); + } + + // ---- data helpers ------------------------------------------------------------------------ + + /** Reads the doc count directly off the node's OWN local driver - never over the wire. */ + private long countOn(PoppyDB node) { + return node.getDriver().count(DB, COLL, Doc.of(), null, null); + } + + private Morphium writerFor(int port, String db) { + MorphiumConfig cfg = new MorphiumConfig(); + cfg.clusterSettings().setHostSeed("localhost:" + port); + cfg.connectionSettings().setDatabase(db); + cfg.connectionSettings().setMaxConnections(10); + cfg.cacheSettings().setBufferedWritesEnabled(false); + return new Morphium(cfg); + } + + private void writeDocs(Morphium writer, int count, String prefix) { + List batch = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + batch.add(new UncachedObject(prefix + "-" + i, i)); + } + writer.storeList(batch, COLL); + } + + /** + * Actively watches convergence after an empty-node restart, for up to {@code timeoutMs}: + *
      + *
    • on EVERY tick, every node in {@code mustNeverLoseData} must still report exactly + * {@code expectedCount} - a real wipe, even a transient one, fails the test the moment + * it is observed, not just if it happens to still be visible at the end;
    • + *
    • on EVERY tick, if {@code restarted} currently claims leadership + * ({@link PoppyDB#isPrimary()}), it must already report {@code expectedCount} locally - + * leadership while still behind/empty is exactly the bug this test pins;
    • + *
    • returns as soon as {@code restarted} itself reaches {@code expectedCount} (legitimate + * convergence via initial sync); fails with a descriptive message if that never happens + * within {@code timeoutMs}.
    • + *
    + */ + private void watchConvergence(PoppyDB restarted, List mustNeverLoseData, + long expectedCount, long timeoutMs) throws Exception { + long deadline = System.currentTimeMillis() + timeoutMs; + while (true) { + for (PoppyDB n : mustNeverLoseData) { + long c = countOn(n); + assertEquals(expectedCount, c, + "a data-bearing node must never lose data while the empty node restarts/resyncs " + + "(got " + c + ", want " + expectedCount + ")"); + } + if (restarted.isPrimary()) { + long c = countOn(restarted); + assertEquals(expectedCount, c, + "the restarted node must never hold/claim leadership before its own data has " + + "fully caught up via legitimate initial sync (local count=" + c + + ", want " + expectedCount + ")"); + } + long restartedCount = countOn(restarted); + if (restartedCount == expectedCount) { + return; // converged: restarted node has legitimately caught up + } + if (System.currentTimeMillis() > deadline) { + fail("restarted node never reached the full document count via initial sync " + + "(got " + restartedCount + ", want " + expectedCount + ") within " + timeoutMs + "ms"); + } + Thread.sleep(150); + } + } + + // ---- Test A: restart the HIGHEST-priority node empty ------------------------------------ + + @Test + public void restartingHighestPriorityNodeEmptyMustNotWipeTheCluster() throws Exception { + int port1 = nextPort(); + int port2 = nextPort(); + int port3 = nextPort(); + PoppyDB node1 = new PoppyDB(port1, "localhost", 20, 5); + PoppyDB node2 = new PoppyDB(port2, "localhost", 20, 5); + PoppyDB node3 = new PoppyDB(port3, "localhost", 20, 5); + List hosts = List.of("localhost:" + port1, "localhost:" + port2, "localhost:" + port3); + Map prio = Map.of( + "localhost:" + port1, 100, + "localhost:" + port2, 90, + "localhost:" + port3, 80); + node1.configureReplicaSet("rsEmptyWipeA", hosts, prio, true, fastTakeoverConfig()); + node2.configureReplicaSet("rsEmptyWipeA", hosts, prio, true, fastTakeoverConfig()); + node3.configureReplicaSet("rsEmptyWipeA", hosts, prio, true, fastTakeoverConfig()); + + startServer(node1, port1); + startServer(node2, port2); + startServer(node3, port3); + waitForPrimary(node1); // priority 100 wins the initial election deterministically + + Morphium writer = writerFor(port1, DB); + try { + writeDocs(writer, DOCS, "pre"); + } finally { + writer.close(); + } + + // Replicated everywhere BEFORE we touch anything - isolates the restart-empty scenario + // below from an ordinary steady-state replication bug. + assertTrue(poll(30_000, () -> countOn(node1) == DOCS && countOn(node2) == DOCS && countOn(node3) == DOCS), + "all " + DOCS + " docs must replicate to every node before the kill (node1=" + countOn(node1) + + ", node2=" + countOn(node2) + ", node3=" + countOn(node3) + ")"); + + log.info("Test A: pre-kill state converged, {} docs on all 3 nodes; killing node1 (highest priority)", DOCS); + + // ---- the exact repro: hard-kill the highest-priority node, restart it EMPTY on the same port ---- + node1.shutdown(); + nodes.remove(node1); + + PoppyDB restarted = new PoppyDB(port1, "localhost", 20, 5); + restarted.configureReplicaSet("rsEmptyWipeA", hosts, prio, true, fastTakeoverConfig()); + startServer(restarted, port1); + + // The heart of the regression: while the cluster converges, node2/node3's real data must + // never be wiped, and the restarted node may only ever claim leadership once it has + // genuinely caught up via initial sync - never while it is still empty/behind. + watchConvergence(restarted, List.of(node2, node3), DOCS, 90_000); + + // Final full-cluster assertion, each read directly off the node's own local driver state. + assertEquals(DOCS, countOn(restarted), "restarted node must have fully synced"); + assertEquals(DOCS, countOn(node2), "node2 must still have all data after convergence"); + assertEquals(DOCS, countOn(node3), "node3 must still have all data after convergence"); + + // Best-effort confirmation that the restarted node reached that count via a legitimate + // initial sync (not e.g. having become primary itself and thus having no ReplicationManager + // to ask - that path is already independently proven correct by watchConvergence above, + // since it could only have claimed leadership once already fully synced). + ReplicationManager restartedRm = restarted.getReplicationManagerForTest(); + if (restartedRm != null) { + assertTrue(restartedRm.isInitialSyncComplete(), + "the restarted node's ReplicationManager must report a COMPLETED initial sync"); + } + + log.info("Test A converged: restarted node reached {} docs, cluster primary is now {}", + countOn(restarted), node2.isPrimary() ? "node2" : (node3.isPrimary() ? "node3" : "restarted")); + } + + // ---- Test B: restart the LOWEST-priority node empty -------------------------------------- + + @Test + public void restartingLowestPriorityNodeEmptyMustNotWipeTheCluster() throws Exception { + int port1 = nextPort(); + int port2 = nextPort(); + int port3 = nextPort(); + PoppyDB node1 = new PoppyDB(port1, "localhost", 20, 5); + PoppyDB node2 = new PoppyDB(port2, "localhost", 20, 5); + PoppyDB node3 = new PoppyDB(port3, "localhost", 20, 5); + List hosts = List.of("localhost:" + port1, "localhost:" + port2, "localhost:" + port3); + Map prio = Map.of( + "localhost:" + port1, 100, + "localhost:" + port2, 90, + "localhost:" + port3, 80); + node1.configureReplicaSet("rsEmptyWipeB", hosts, prio, true, fastTakeoverConfig()); + node2.configureReplicaSet("rsEmptyWipeB", hosts, prio, true, fastTakeoverConfig()); + node3.configureReplicaSet("rsEmptyWipeB", hosts, prio, true, fastTakeoverConfig()); + + startServer(node1, port1); + startServer(node2, port2); + startServer(node3, port3); + waitForPrimary(node1); // priority 100 wins the initial election deterministically + + Morphium writer = writerFor(port1, DB); + try { + writeDocs(writer, DOCS, "pre"); + } finally { + writer.close(); + } + + assertTrue(poll(30_000, () -> countOn(node1) == DOCS && countOn(node2) == DOCS && countOn(node3) == DOCS), + "all " + DOCS + " docs must replicate to every node before the kill (node1=" + countOn(node1) + + ", node2=" + countOn(node2) + ", node3=" + countOn(node3) + ")"); + + log.info("Test B: pre-kill state converged, {} docs on all 3 nodes; killing node3 (lowest priority)", DOCS); + + // ---- restart the LOWEST-priority node empty: node1 stays primary throughout, no ---- + // ---- re-election is even needed - this isolates the wipe-on-resync half of the bug ---- + // ---- from the vote/candidacy half that Test A exercises. ---- + node3.shutdown(); + nodes.remove(node3); + + PoppyDB restarted = new PoppyDB(port3, "localhost", 20, 5); + restarted.configureReplicaSet("rsEmptyWipeB", hosts, prio, true, fastTakeoverConfig()); + startServer(restarted, port3); + + // node1 (still primary, highest priority, never touched) and node2 must never lose data; + // the restarted lowest-priority node must never claim leadership before catching up + // (trivially true here since node1 never yields it, but the same watch applies uniformly). + watchConvergence(restarted, List.of(node1, node2), DOCS, 90_000); + + assertEquals(DOCS, countOn(restarted), "restarted node must have fully synced"); + assertEquals(DOCS, countOn(node1), "node1 (primary throughout) must still have all data"); + assertEquals(DOCS, countOn(node2), "node2 must still have all data after convergence"); + assertTrue(node1.isPrimary(), "node1 must have remained primary the whole time - no failover was needed"); + + ReplicationManager restartedRm = restarted.getReplicationManagerForTest(); + if (restartedRm != null) { + assertTrue(restartedRm.isInitialSyncComplete(), + "the restarted node's ReplicationManager must report a COMPLETED initial sync"); + } + + log.info("Test B converged: restarted node reached {} docs, node1 remained primary throughout", countOn(restarted)); + } +} diff --git a/poppydb/src/test/java/de/caluga/poppydb/InitialSyncChangeStreamSilenceTest.java b/poppydb/src/test/java/de/caluga/poppydb/InitialSyncChangeStreamSilenceTest.java new file mode 100644 index 000000000..cf5debbfb --- /dev/null +++ b/poppydb/src/test/java/de/caluga/poppydb/InitialSyncChangeStreamSilenceTest.java @@ -0,0 +1,237 @@ +package de.caluga.poppydb; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import de.caluga.morphium.driver.Doc; +import de.caluga.morphium.driver.DriverTailableIterationCallback; +import de.caluga.morphium.driver.commands.InsertMongoCommand; +import de.caluga.morphium.driver.commands.WatchCommand; +import de.caluga.morphium.driver.commands.auth.CreateUserAdminCommand; +import de.caluga.morphium.driver.inmem.InMemoryDriver; + +/** + * Regression test for the {@link StepdownReplicationTest} flake (post-stepdown user never + * reaching node3): a secondary's initial sync used to be OBSERVABLE via its own change stream. + * {@code clearLocalDatabases()} wipes the local data with regular drop/dropDatabase commands, + * and those emitted live change-stream events - including {@code drop admin.system.users}. + * + *

    During a leadership transition that is catastrophic: the demoted ex-primary immediately + * starts re-syncing toward the presumed new leader, and each (re)try of its snapshot wipes its + * local databases. The OTHER nodes' old ReplicationManagers are still watching the demoted node + * (they tear down only once their own ElectionManager delivers the leader change) and faithfully + * apply the wipe's drop events to their own data - observed in the ambient logs as a storm of + * {@code admin.system.users} drops ricocheting around all three nodes, with even the freshly + * promoted primary applying the demoted node's wipe-drop right at its own promotion (its + * stopping ReplicationManager flushes the queued stale drops). Whether + * StepdownReplicationTest's post-stepdown user survived was then pure timing: if a stale drop + * reached a node after that node had already picked up the user (via snapshot or stream), the + * user was destroyed there with nothing left to re-deliver it - the ~40% flake. + * + *

    Contract pinned here (mirrors MongoDB, where initial-sync writes are never oplogged): the + * initial sync - both the {@code clearLocalDatabases()} wipe and the snapshot copy - must not + * emit ANY change-stream events on the syncing node. A watcher subscribed to the syncing node + * (standing in for another node's stale ReplicationManager) must observe nothing. + */ +@Tag("server") +public class InitialSyncChangeStreamSilenceTest { + + private PoppyDB leader; + private ReplicationManager rm; + private InMemoryDriver local; + + @AfterEach + public void tearDown() { + if (rm != null) { + try { + rm.stop(); + } catch (Exception ignored) { + } + } + if (local != null) { + try { + local.close(); + } catch (Exception ignored) { + } + } + if (leader != null) { + try { + leader.shutdown(); + } catch (Exception ignored) { + } + } + } + + private int nextPort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private void startServer(PoppyDB srv, int port) throws Exception { + srv.start(); + long deadline = System.currentTimeMillis() + 10_000; + while (true) { + try (Socket s = new Socket()) { + s.connect(new InetSocketAddress("localhost", port), 250); + return; + } catch (Exception e) { + if (System.currentTimeMillis() > deadline) { + throw e; + } + Thread.sleep(50); + } + } + } + + private void createUser(InMemoryDriver drv, String user, String pwd) throws Exception { + CreateUserAdminCommand cmd = new CreateUserAdminCommand(null).setUserName(user).setPwd(pwd); + cmd.setDb("admin"); + Map result = drv.readSingleAnswer(drv.runCommand(cmd)); + assertEquals(1.0, result.get("ok"), "createUser must succeed: " + result); + } + + /** Collects every event delivered to a cluster-level watch on the given driver. */ + private static class ClusterWatch { + final List> events = Collections.synchronizedList(new ArrayList<>()); + final AtomicBoolean running = new AtomicBoolean(true); + final CountDownLatch registered = new CountDownLatch(1); + Thread thread; + + void stop() throws InterruptedException { + running.set(false); + thread.join(5000); + } + } + + /** + * Subscribes to the local driver's change stream exactly the way another PoppyDB node's + * ReplicationManager would (db "admin" = cluster level, empty pipeline) - this watcher plays + * the role of a stale RM still pointed at the demoted/syncing node. + */ + private ClusterWatch subscribeClusterWatch(InMemoryDriver drv) throws Exception { + ClusterWatch cw = new ClusterWatch(); + var con = drv.getPrimaryConnection(null); + WatchCommand watch = new WatchCommand(con) + .setDb("admin") + .setMaxTimeMS(200) + .setFullDocument(WatchCommand.FullDocumentEnum.updateLookup) + .setPipeline(List.of()) + .setRegistrationCallback(cw.registered::countDown) + .setCb(new DriverTailableIterationCallback() { + @Override + public void incomingData(Map data, long dur) { + cw.events.add(data); + } + + @Override + public boolean isContinued() { + return cw.running.get(); + } + }); + cw.thread = Thread.ofVirtual().start(() -> { + try { + watch.watch(); + } catch (Exception e) { + // stream torn down on stop - nothing to do + } finally { + watch.releaseConnection(); + } + }); + assertTrue(cw.registered.await(5, TimeUnit.SECONDS), "watch never registered"); + return cw; + } + + private static String describe(Map event) { + return event.get("operationType") + " on " + event.get("ns"); + } + + @Test + public void initialSyncEmitsNoChangeStreamEvents() throws Exception { + int port = nextPort(); + leader = new PoppyDB(port, "localhost", 20, 5); + startServer(leader, port); + assertTrue(leader.isPrimary(), "standalone PoppyDB must act as primary"); + + // The primary's authoritative state: one user, one data collection. + createUser(leader.getDriver(), "leader-user", "leader-pw"); + new InsertMongoCommand(leader.getDriver()).setDb("datadb").setColl("docs") + .setDocuments(List.of(Doc.of("_id", 1, "v", "fresh"))) + .execute(); + + // The syncing node's STALE local state - both a stale user (so the wipe's + // drop("admin","system.users") acts on a non-empty collection) and a stale database + // (so clearLocalDatabases has a dropDatabase to do). The divergence also guarantees + // the consistency shortcut fails and the full wipe + snapshot path runs. + local = new InMemoryDriver(); + local.connect(); + createUser(local, "stale-user", "stale-pw"); + new InsertMongoCommand(local).setDb("staledb").setColl("old") + .setDocuments(List.of(Doc.of("_id", 1, "v", "stale"))) + .execute(); + + // Stale-RM stand-in: watch the syncing node BEFORE its initial sync starts. + ClusterWatch cw = subscribeClusterWatch(local); + try { + rm = new ReplicationManager(local, "localhost", port); + rm.setMyAddress("localhost:test-secondary"); + rm.start(); + assertTrue(rm.waitForInitialSync(30, TimeUnit.SECONDS), + "initial sync must complete within 30s"); + + // Sanity: the full path (wipe + snapshot) actually ran - a shortcut sync would + // trivially emit nothing and pin the wrong thing. + assertFalse(rm.wasLastSyncShortcut(), "test must exercise the full wipe + snapshot path"); + assertTrue(rm.getClearLocalDatabasesInvocationsForTest() >= 1, + "clearLocalDatabases must have run"); + + // Sanity: the sync itself worked - the primary's state replaced the stale state. + assertEquals(1, local.findByFieldValue("admin", "system.users", "_id", "admin.leader-user").size(), + "the primary's user must have been copied"); + assertTrue(local.findByFieldValue("admin", "system.users", "_id", "admin.stale-user").isEmpty(), + "the stale local user must be gone after the sync"); + + // Grace period for any late asynchronous dispatch before asserting silence. + Thread.sleep(500); + } finally { + cw.stop(); + } + + List destructive; + List all; + synchronized (cw.events) { + destructive = cw.events.stream() + .filter(e -> "drop".equals(e.get("operationType")) || "dropDatabase".equals(e.get("operationType"))) + .map(InitialSyncChangeStreamSilenceTest::describe).toList(); + all = cw.events.stream().map(InitialSyncChangeStreamSilenceTest::describe).toList(); + } + + // THE regression assertion: the wipe must not be observable. Pre-fix this collected + // "drop on {db=admin, coll=system.users}" and "dropDatabase on {db=staledb}" - the very + // events that, applied by other nodes' stale ReplicationManagers, destroyed + // admin.system.users cluster-wide during the stepdown transition. + assertTrue(destructive.isEmpty(), + "a node's initial-sync wipe must not emit change-stream events (stale watchers of a " + + "demoted node would apply them and destroy their own data), but got: " + destructive); + + // And the snapshot copy must be equally silent (MongoDB: initial sync is not oplogged). + assertTrue(all.isEmpty(), + "the initial sync (wipe + snapshot copy) must emit NO change-stream events at all, but got: " + all); + } +} diff --git a/poppydb/src/test/java/de/caluga/poppydb/InitialSyncElectionSeedTest.java b/poppydb/src/test/java/de/caluga/poppydb/InitialSyncElectionSeedTest.java new file mode 100644 index 000000000..8587b9ea4 --- /dev/null +++ b/poppydb/src/test/java/de/caluga/poppydb/InitialSyncElectionSeedTest.java @@ -0,0 +1,131 @@ +package de.caluga.poppydb; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import de.caluga.morphium.driver.Doc; +import de.caluga.morphium.driver.commands.InsertMongoCommand; +import de.caluga.morphium.driver.inmem.InMemoryDriver; +import de.caluga.poppydb.election.ElectionManager; +import de.caluga.poppydb.election.ElectionConfig; + +/** + * Integration-level regression test for the "freshly-synced but silent" leg of the + * empty-node-wipe bug: a node that just completed an initial sync (full snapshot or consistency + * shortcut - both converge on the same "success: open the gate" block in the replication loop, + * see that block's comment in {@link ReplicationManager}) must report its real, non-zero + * replication position to {@link ElectionManager} immediately, even if it goes on to apply ZERO + * live events afterward (a quiet primary). Before this fix, only {@code processBatch()}'s + * per-applied-batch call fed {@code onLogIndexUpdate}, so a freshly-synced-then-silent node kept + * reporting index 0 - wrongly granting votes to genuinely empty candidates as voter (reopening + * the wipe), and wrongly getting denied as candidate. + * + *

    Uses the same lightweight harness as {@link InitialSyncChangeStreamSilenceTest}: a real + * standalone {@link PoppyDB} primary plus a bare {@link ReplicationManager} pointed directly at + * it (no multi-node election machinery, no {@code @Disabled} - this stays fast and always-on). + * The {@code ElectionManager} here is not attached to a live election (no peers, never + * started/stopped) - it exists purely as the production wiring target for + * {@code setOnLogIndexUpdate}, exactly as {@link PoppyDB#startReplicationToLeader} wires it. + */ +@Tag("server") +public class InitialSyncElectionSeedTest { + + private PoppyDB leader; + private ReplicationManager rm; + private InMemoryDriver local; + + @AfterEach + public void tearDown() { + if (rm != null) { + try { + rm.stop(); + } catch (Exception ignored) { + } + } + if (local != null) { + try { + local.close(); + } catch (Exception ignored) { + } + } + if (leader != null) { + try { + leader.shutdown(); + } catch (Exception ignored) { + } + } + } + + private int nextPort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private void startServer(PoppyDB srv, int port) throws Exception { + srv.start(); + long deadline = System.currentTimeMillis() + 10_000; + while (true) { + try (Socket s = new Socket()) { + s.connect(new InetSocketAddress("localhost", port), 250); + return; + } catch (Exception e) { + if (System.currentTimeMillis() > deadline) { + throw e; + } + Thread.sleep(50); + } + } + } + + @Test + public void freshlySyncedNodeReportsNonZeroIndexWithoutAnyLiveEvent() throws Exception { + int port = nextPort(); + leader = new PoppyDB(port, "localhost", 20, 5); + startServer(leader, port); + assertTrue(leader.isPrimary(), "standalone PoppyDB must act as primary"); + + // Give the primary real, pre-existing data BEFORE the secondary ever connects, so its + // change-stream sequence is genuinely non-zero and the secondary's initial-sync seed + // (recordPrimarySequenceAtRegistration) has something real to seed from. + new InsertMongoCommand(leader.getDriver()).setDb("datadb").setColl("docs") + .setDocuments(List.of(Doc.of("_id", 1, "v", "fresh"))) + .execute(); + + local = new InMemoryDriver(); + local.connect(); + + ElectionConfig config = new ElectionConfig() + .setElectionTimeoutMinMs(60_000) + .setElectionTimeoutMaxMs(60_000); + ElectionManager electionManager = new ElectionManager( + "localhost:test-secondary", List.of("localhost:test-secondary"), config); + // Not started: this test only exercises updateLogIndex() as a wiring target, not the + // election protocol itself (that's ElectionLogRecencyTest's job). + + rm = new ReplicationManager(local, "localhost", port); + rm.setMyAddress("localhost:test-secondary"); + // Exactly the wiring PoppyDB#startReplicationToLeader installs in production. + rm.setOnLogIndexUpdate((index, term) -> + electionManager.updateLogIndex(index, electionManager.getCurrentTerm())); + rm.start(); + assertTrue(rm.waitForInitialSync(30, TimeUnit.SECONDS), "initial sync must complete within 30s"); + + // No write happens on the primary after this point in this test - the secondary applies + // zero live events. Before this fix, ElectionManager's lastLogIndex would still be 0 here. + assertTrue(electionManager.getLastLogIndex() > 0, + "a freshly-synced node must report a non-zero replication position to " + + "ElectionManager even without applying any live event afterward " + + "(got lastLogIndex=" + electionManager.getLastLogIndex() + ", " + + "ReplicationManager lastAppliedSequence=" + rm.getLastAppliedSequence() + ")"); + } +} diff --git a/poppydb/src/test/java/de/caluga/poppydb/LeadershipEpochGuardTest.java b/poppydb/src/test/java/de/caluga/poppydb/LeadershipEpochGuardTest.java index bbd9bc063..4e5c0b519 100644 --- a/poppydb/src/test/java/de/caluga/poppydb/LeadershipEpochGuardTest.java +++ b/poppydb/src/test/java/de/caluga/poppydb/LeadershipEpochGuardTest.java @@ -106,4 +106,53 @@ void staleFollowerBodyCannotClearANewerLeaderBodysCoordinator() { assertSame(coordinatorAfterLeaderBody, db.getReplicationCoordinator(), "stale follower body must not clear the coordinator a newer leader body just set up"); } + + /** + * The flag-side counterpart of the epoch guard (2026-08-06 review finding): the epoch bump + * and the {@code primary} flip must be ONE atomic unit. Before the fix, the wrapper did + * {@code incrementAndGet()} and then wrote {@code primary} unsynchronized - a preempted + * stale true-dispatch could re-assert {@code primary==true} AFTER a newer false-dispatch + * already wrote the current value, leaving a demoted leader that silently never replicates + * (startReplicationToLeader, the liveness probe and the retry chain all no-op on + * {@code primary}). This hammers {@link PoppyDB#applyLeadershipFlip(boolean)} from many + * threads and asserts the flag always ends up matching the transition that owns the + * HIGHEST epoch - on the old unsynchronized write this inverts within a few hundred + * iterations. + */ + @Test + void primaryFlagAlwaysMatchesTheNewestEpochsTransition() throws Exception { + db = electionModeNode(); + + int threads = 8; + int iterationsPerThread = 500; + java.util.concurrent.ConcurrentHashMap byEpoch = new java.util.concurrent.ConcurrentHashMap<>(); + java.util.concurrent.CyclicBarrier startLine = new java.util.concurrent.CyclicBarrier(threads); + java.util.List workers = new java.util.ArrayList<>(); + + for (int t = 0; t < threads; t++) { + boolean isLeader = t % 2 == 0; // half the threads flip true, half false + Thread w = new Thread(() -> { + try { + startLine.await(); + } catch (Exception e) { + throw new RuntimeException(e); + } + for (int i = 0; i < iterationsPerThread; i++) { + long epoch = db.applyLeadershipFlip(isLeader); + byEpoch.put(epoch, isLeader); + } + }, "flip-" + t); + workers.add(w); + w.start(); + } + for (Thread w : workers) { + w.join(30000); + } + + long maxEpoch = byEpoch.keySet().stream().mapToLong(Long::longValue).max().orElseThrow(); + assertEquals(threads * iterationsPerThread, byEpoch.size(), + "every flip must have received a unique epoch"); + assertEquals(byEpoch.get(maxEpoch), db.isPrimary(), + "primary flag must reflect the transition holding the newest epoch, never a stale overwrite"); + } } diff --git a/poppydb/src/test/java/de/caluga/poppydb/MessagingOneWayThroughputBenchmark.java b/poppydb/src/test/java/de/caluga/poppydb/MessagingOneWayThroughputBenchmark.java new file mode 100644 index 000000000..220ffd2f3 --- /dev/null +++ b/poppydb/src/test/java/de/caluga/poppydb/MessagingOneWayThroughputBenchmark.java @@ -0,0 +1,174 @@ +package de.caluga.poppydb; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.MorphiumConfig; +import de.caluga.morphium.messaging.MorphiumMessaging; +import de.caluga.morphium.messaging.Msg; + +/** + * ONE-WAY messaging throughput: N messages from a sender to a single listening receiver, + * measured from first send to last RECEIPT — no replies, no request/response round-trip. + * This is the counterpart to the round-trip (ping-pong) numbers in + * docs/v5-vs-v6-performance.md ("Messaging Performance by Backend": 89 msg/s MongoDB RS, + * 223 msg/s PoppyDB) and exists to give the README comparison table a SOURCED one-way figure + * per backend instead of the historic, no-longer-reproducible "~8K msg/s" claim. + * + *

    Tagged {@code manual}: this is a benchmark, not a regression test — its assertions only + * pin that every message arrived, never a rate (rates depend entirely on the host). Run it + * explicitly, ideally on the same infrastructure as the other benchmark numbers: + * + *

    + *   # PoppyDB (in-process server):
    + *   mvn -pl morphium-core,poppydb -am surefire:test \
    + *     -Dtest=MessagingOneWayThroughputBenchmark#oneWayThroughputPoppyDB -Dtest.excludeTags=
    + *
    + *   # MongoDB (external, e.g. the 3-node homelab RS):
    + *   mvn -pl morphium-core,poppydb -am surefire:test \
    + *     -Dtest=MessagingOneWayThroughputBenchmark#oneWayThroughputMongoDB -Dtest.excludeTags= \
    + *     -Dmorphium.uri=mongodb://mongo1:27017,mongo2:27017/morphium_tests
    + * 
    + * + * Results are printed as a single greppable line: {@code ONEWAY-RESULT backend=... rate=...}. + */ +@Tag("manual") +public class MessagingOneWayThroughputBenchmark { + + private static final String TOPIC = "onewaybench"; + private static final int MESSAGES = 5000; + private static final int SENDER_THREADS = 4; + private static final long RECEIVE_DEADLINE_MS = 300_000; + + private PoppyDB server; + + @AfterEach + void tearDown() { + if (server != null) { + server.shutdown(); + server = null; + } + } + + @Test + public void oneWayThroughputPoppyDB() throws Exception { + int port; + try (ServerSocket s = new ServerSocket(0)) { + port = s.getLocalPort(); + } + server = new PoppyDB(port, "localhost", 100, 10); + server.start(); + long deadline = System.currentTimeMillis() + 10_000; + while (true) { + try (Socket s = new Socket()) { + s.connect(new InetSocketAddress("localhost", port), 250); + break; + } catch (Exception e) { + if (System.currentTimeMillis() > deadline) throw e; + Thread.sleep(50); + } + } + + runOneWay("poppydb", "localhost:" + port); + } + + @Test + public void oneWayThroughputMongoDB() throws Exception { + String uri = System.getProperty("morphium.uri", System.getenv("MONGODB_URI")); + assumeTrue(uri != null && !uri.isBlank(), + "no external MongoDB configured - pass -Dmorphium.uri=mongodb://host1,host2/db"); + + // minimal parse: mongodb://host1:port,host2:port/db (no credentials - benchmark infra) + String hostsPart = uri.replaceFirst("^mongodb://", ""); + if (hostsPart.contains("/")) { + hostsPart = hostsPart.substring(0, hostsPart.indexOf('/')); + } + runOneWay("mongodb", hostsPart.split(",")); + } + + private void runOneWay(String backendLabel, String... hostSeed) throws Exception { + String db = "oneway_bench_" + System.currentTimeMillis(); + + try (Morphium receiverMorphium = new Morphium(cfg(db, hostSeed)); + Morphium senderMorphium = new Morphium(cfg(db, hostSeed))) { + + MorphiumMessaging receiver = receiverMorphium.createMessaging(); + AtomicInteger received = new AtomicInteger(); + receiver.addListenerForTopic(TOPIC, (mq, msg) -> { + received.incrementAndGet(); + return null; // one-way: never answer + }); + receiver.start(); + + MorphiumMessaging sender = senderMorphium.createMessaging(); + sender.start(); + + // let both messaging instances register their change streams before the clock starts + Thread.sleep(3000); + + long start = System.nanoTime(); + + List senders = new ArrayList<>(); + int perThread = MESSAGES / SENDER_THREADS; + for (int t = 0; t < SENDER_THREADS; t++) { + Thread worker = new Thread(() -> { + for (int i = 0; i < perThread; i++) { + // 5min TTL so no message can expire mid-run on a slow backend + sender.sendMessage(new Msg(TOPIC, "bench", "x", 300_000)); + } + }, "oneway-sender-" + t); + senders.add(worker); + worker.start(); + } + for (Thread worker : senders) { + worker.join(); + } + long sendDoneNanos = System.nanoTime() - start; + + int expected = perThread * SENDER_THREADS; + long receiveDeadline = System.currentTimeMillis() + RECEIVE_DEADLINE_MS; + while (received.get() < expected && System.currentTimeMillis() < receiveDeadline) { + Thread.sleep(50); + } + long totalNanos = System.nanoTime() - start; + + assertEquals(expected, received.get(), + "every sent message must arrive (one-way) - backend " + backendLabel); + + double sendRate = expected / (sendDoneNanos / 1e9); + double endToEndRate = expected / (totalNanos / 1e9); + System.out.printf( + "ONEWAY-RESULT backend=%s messages=%d senderThreads=%d sendSeconds=%.2f sendRate=%.0f msg/s " + + "endToEndSeconds=%.2f endToEndRate=%.0f msg/s%n", + backendLabel, expected, SENDER_THREADS, sendDoneNanos / 1e9, sendRate, + totalNanos / 1e9, endToEndRate); + + receiver.terminate(); + sender.terminate(); + } + } + + private MorphiumConfig cfg(String db, String... hostSeed) { + MorphiumConfig cfg = new MorphiumConfig(); + cfg.connectionSettings().setDatabase(db); + cfg.clusterSettings().getHostSeed().clear(); + for (String h : hostSeed) { + cfg.clusterSettings().addHostToSeed(h); + } + cfg.driverSettings().setDriverName("PooledDriver"); + cfg.connectionSettings().setMaxConnections(20).setMinConnections(2); + return cfg; + } +} diff --git a/poppydb/src/test/java/de/caluga/poppydb/PoppyDBCLIParseTest.java b/poppydb/src/test/java/de/caluga/poppydb/PoppyDBCLIParseTest.java index bc5ce65d2..1462c900b 100644 --- a/poppydb/src/test/java/de/caluga/poppydb/PoppyDBCLIParseTest.java +++ b/poppydb/src/test/java/de/caluga/poppydb/PoppyDBCLIParseTest.java @@ -170,4 +170,49 @@ void helpFlagIsToleratedWithoutSideEffects() { ServerOptions opts = PoppyDBCLI.parse(new String[] {"--help", "--port", "4711"}, 0); assertThat(opts.port).isEqualTo(4711); } + + // --- replay-buffer (spec 2026-08-14-replay-buffer-byte-budget.md) --- + + @Test + void replayBufferDefaultsTo256mAndIsParsedFromCli() { + ServerOptions defaults = PoppyDBCLI.parse(new String[0], 0); + assertThat(defaults.replayBuffer).isEqualTo("256m"); + assertThat(defaults.sourceOf("replay-buffer")).isEqualTo(ServerOptions.Source.DEFAULT); + + ServerOptions opts = PoppyDBCLI.parse(new String[] {"--replay-buffer", "5%"}, 0); + assertThat(opts.replayBuffer).isEqualTo("5%"); + assertThat(opts.sourceOf("replay-buffer")).isEqualTo(ServerOptions.Source.CLI); + } + + @Test + void replayBufferSizesResolveFixedAndPercent() { + long heap = 1024L * 1024 * 1024; // pretend 1 GB max heap + assertThat(ServerOptions.parseReplayBufferBytes("256m", heap)).isEqualTo(256L * 1024 * 1024); + assertThat(ServerOptions.parseReplayBufferBytes("1g", heap)).isEqualTo(1024L * 1024 * 1024); + assertThat(ServerOptions.parseReplayBufferBytes("64k", heap)).isEqualTo(64L * 1024); + assertThat(ServerOptions.parseReplayBufferBytes("12345", heap)).isEqualTo(12345L); + assertThat(ServerOptions.parseReplayBufferBytes("5%", heap)).isEqualTo(heap / 20); + assertThat(ServerOptions.parseReplayBufferBytes("0", heap)).isZero(); + assertThat(ServerOptions.parseReplayBufferBytes(" 1G ", heap)).isEqualTo(1024L * 1024 * 1024); + } + + @Test + void replayBufferInvalidValuesAreRejected() { + long heap = 1024L * 1024 * 1024; + assertThatThrownBy(() -> ServerOptions.parseReplayBufferBytes("abc", heap)) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("abc"); + assertThatThrownBy(() -> ServerOptions.parseReplayBufferBytes("150%", heap)) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("150%"); + assertThatThrownBy(() -> ServerOptions.parseReplayBufferBytes("-5m", heap)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> ServerOptions.parseReplayBufferBytes("", heap)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void replayBufferInvalidValueIsReportedByValidate() { + ServerOptions opts = PoppyDBCLI.parse(new String[] {"--replay-buffer", "lots"}, 0); + ConfigInspector.Result result = ConfigInspector.validate(opts); + assertThat(result.errors()).anyMatch(e -> e.contains("lots")); + } } diff --git a/poppydb/src/test/java/de/caluga/poppydb/ReplicationFailClosedTest.java b/poppydb/src/test/java/de/caluga/poppydb/ReplicationFailClosedTest.java new file mode 100644 index 000000000..b1bc1b215 --- /dev/null +++ b/poppydb/src/test/java/de/caluga/poppydb/ReplicationFailClosedTest.java @@ -0,0 +1,853 @@ +package de.caluga.poppydb; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Callable; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import ch.qos.logback.classic.Level; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.MorphiumConfig; +import de.caluga.morphium.driver.Doc; +import de.caluga.morphium.driver.commands.GenericCommand; +import de.caluga.morphium.driver.inmem.InMemoryDriver; +import de.caluga.test.mongo.suite.data.UncachedObject; + +/** + * Fail-closed destructive resync (D2) + shortcut namespace union (D4) - 2026-08-14 + * empty-node-wipe fix, task 3. + * + *

    Reproduces, at the {@link ReplicationManager} level (no election/RS machinery needed - a + * ReplicationManager talks to a fixed {@code primaryHost:primaryPort} regardless of RS/leader + * state), the exact kill chain from the bug report: a follower holding real data reconnects to + * whatever now answers at that address, and if that "primary" turns out to be a freshly + * restarted, empty process, its change-stream sequence counter necessarily starts fresh (0-ish) - + * behind the sequence our local data was last known to reflect. Before the fix, the follower + * would trust that empty state and wipe its own data to match it. The fix refuses instead. + * + *

    The "primary restarted empty" step is reproduced literally: a standalone primary is fed data + * and live-replicates it to a manually-wired {@link ReplicationManager}; the connection is then + * severed ({@link ReplicationManager#pauseReplicationForTest()}), the primary process is shut + * down (destroying its in-memory state), and a brand-new, empty {@code PoppyDB} is started on the + * SAME port before the connection is healed again ({@link ReplicationManager#resumeReplicationForTest()}). + * The follower's next reconnect necessarily hits the primary's shrunk replay buffer ("resume + * window lost"), which is exactly the fallback branch the bug report's log lines show. + * + *

      + *
    • {@link #refusesWhenReconnectedPrimaryIsBehind()} - case (a): the freshly-restarted primary + * is empty AND behind (sequence 0-ish) - the follower must refuse, keep its data, log an + * ERROR, and surface the refusal in stats.
    • + *
    • {@link #proceedsWhenReconnectedPrimaryIsEmptyButCaughtUp()} - case (b): the + * freshly-restarted primary is also empty, but its sequence has been advanced (by other + * writes then a drop) past the follower's local sequence - a legitimate post-dropDatabase + * shape. The resync must proceed exactly as before this fix.
    • + *
    • {@link #shortcutNotTakenWhenLocalHasExtraNamespace()} - case (c) / D4: a follower whose + * local state has an extra namespace the (still fully caught-up, never-restarted) primary + * does not must NOT take the consistency shortcut - the namespace comparison must be a + * union (local-only namespaces count as mismatch), not an intersection that could miss + * this and leave the extra namespace behind forever.
    • + *
    + */ +@Tag("server") +public class ReplicationFailClosedTest { + + private static final Logger log = LoggerFactory.getLogger(ReplicationFailClosedTest.class); + + private static final String DB = "failclosedtest"; + private static final String COLL = "objs"; + private static final int DOCS = 20; + + /** Started nodes, shut down in reverse start order on teardown. */ + private final List nodes = new ArrayList<>(); + /** Extra ReplicationManager instances (RM-replacement tests use more than one) to stop on teardown. */ + private final List extraReplicationManagers = new ArrayList<>(); + private ReplicationManager rm; + private InMemoryDriver local; + + @AfterEach + public void tearDown() { + if (rm != null) { + try { + rm.stop(); + } catch (Exception ignored) { + } + } + for (int i = extraReplicationManagers.size() - 1; i >= 0; i--) { + try { + extraReplicationManagers.get(i).stop(); + } catch (Exception ignored) { + } + } + extraReplicationManagers.clear(); + if (local != null) { + try { + local.close(); + } catch (Exception ignored) { + } + } + for (int i = nodes.size() - 1; i >= 0; i--) { + try { + nodes.get(i).shutdown(); + } catch (Exception ignored) { + } + } + nodes.clear(); + } + + // ---- bootstrap helpers (pattern of ReplicationResumeTest / FastResyncTest) -------------- + + private int nextPort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + /** Starts a standalone (no RS config -> immediately primary) PoppyDB node, tracked for teardown. */ + private PoppyDB startStandalonePrimary(int port) throws Exception { + PoppyDB srv = new PoppyDB(port, "localhost", 20, 5); + nodes.add(srv); + srv.start(); + long deadline = System.currentTimeMillis() + 10_000; + while (true) { + try (Socket s = new Socket()) { + s.connect(new InetSocketAddress("localhost", port), 250); + return srv; + } catch (Exception e) { + if (System.currentTimeMillis() > deadline) { + throw e; + } + Thread.sleep(50); + } + } + } + + private boolean poll(long timeoutMs, Callable condition) throws Exception { + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) { + if (Boolean.TRUE.equals(condition.call())) { + return true; + } + Thread.sleep(100); + } + return Boolean.TRUE.equals(condition.call()); + } + + private long localCount() throws Exception { + return local.count(DB, COLL, Doc.of(), null, null); + } + + /** Count of "Starting change stream watch on primary..." lines captured so far - one per watch registration. */ + private long registrationLogCount(ListAppender appender) { + return appender.list.stream() + .filter(ev -> ev.getFormattedMessage().contains("Starting change stream watch on primary")) + .count(); + } + + private void writeDocs(Morphium writer, int count, String prefix) { + List batch = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + batch.add(new UncachedObject(prefix + "-" + i, i)); + } + writer.storeList(batch, COLL); + } + + private Morphium writerFor(int port, String db) { + MorphiumConfig cfg = new MorphiumConfig(); + cfg.clusterSettings().setHostSeed("localhost:" + port); + cfg.connectionSettings().setDatabase(db); + cfg.connectionSettings().setMaxConnections(10); + cfg.cacheSettings().setBufferedWritesEnabled(false); + return new Morphium(cfg); + } + + /** + * Copies the follower's CURRENT {@code DB.COLL} documents, verbatim, into a target node's + * driver - the same doc {@code Map}s {@link InMemoryDriver#find} returns, re-inserted as-is. + * Unlike two independent inserts built from scratch (which do not reliably dbHash-match - + * apparently not just field content but some internal representation detail differs), this + * guarantees a genuine dbHash match: it is a literal copy of the exact bytes replication + * already produced, the same technique {@code performInitialSync}'s own {@code syncCollection} + * uses to seed a follower from a primary. + */ + private void copyLocalDataInto(PoppyDB target) throws Exception { + List> docs = local.find(DB, COLL, Doc.of(), null, null, 0, 1000); + GenericCommand cmd = new GenericCommand(target.getDriver()); + cmd.setDb(DB); + cmd.setColl(COLL); + cmd.setCmdData(Doc.of("insert", COLL, "$db", DB, "documents", docs)); + target.getDriver().runCommand(cmd); + } + + /** + * Inserts {@code count} documents with DETERMINISTIC {@code _id}s (unlike {@link #writeDocs}, + * whose {@link UncachedObject}s get a fresh random {@code MorphiumId} on every call) directly + * into a target node's driver - bypassing Morphium/the wire protocol, same pattern as the + * dbHash-comparison tests elsewhere in this file. + */ + private void insertFixedDocs(PoppyDB target, int count, String prefix) throws Exception { + List> docs = new ArrayList<>(count); + for (int i = 0; i < count; i++) { + docs.add(Doc.of("_id", prefix + "-" + i, "value", i)); + } + GenericCommand cmd = new GenericCommand(target.getDriver()); + cmd.setDb(DB); + cmd.setColl(COLL); + cmd.setCmdData(Doc.of("insert", COLL, "$db", DB, "documents", docs)); + target.getDriver().runCommand(cmd); + } + + /** + * Common setup for cases (a) and (b): a standalone primary fed {@link #DOCS} documents, + * live-replicated to a manually-wired {@link ReplicationManager}, its replay buffer then + * shrunk so a subsequent gap cannot be resumed from the buffer (forcing the primary to + * answer "resume window lost" rather than silently truncating - the same trick + * {@code ReplicationResumeTest#bufferMissTriggersResync} uses). + * + * @return the follower's lastAppliedSequence right after live replication converged (S in + * the class javadoc / bug report). + */ + private long bootstrapFollowerWithData(int port1) throws Exception { + PoppyDB primary = startStandalonePrimary(port1); + + local = new InMemoryDriver(); + local.connect(); + rm = new ReplicationManager(local, "localhost", port1); + rm.start(); + assertTrue(poll(30_000, rm::isInitialSyncComplete), "initial (trivially empty) sync must complete"); + + Morphium writer = writerFor(port1, DB); + try { + writeDocs(writer, DOCS, "pre"); + assertTrue(poll(30_000, () -> localCount() == DOCS), + "follower must live-replicate the batch (got " + localCount() + ")"); + } finally { + writer.close(); + } + assertTrue(poll(5_000, () -> rm.getLastAppliedSequence() > 0), + "lastAppliedSequence must have advanced past 0 via live replication"); + long s = rm.getLastAppliedSequence(); + + // Shrink the buffer so the upcoming gap cannot be resumed from it. + primary.getDriver().setChangeStreamHistoryLimit(2); + return s; + } + + // ---- case (a): reconnected primary is behind -> refuse ----------------------------------- + + @Test + public void refusesWhenReconnectedPrimaryIsBehind() throws Exception { + int port1 = nextPort(); + long s = bootstrapFollowerWithData(port1); + assertEquals(0, rm.getRefusedResyncCount(), "no refusal should have happened yet"); + + ch.qos.logback.classic.Logger rmLogger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(ReplicationManager.class); + ListAppender appender = new ListAppender<>(); + appender.start(); + rmLogger.addAppender(appender); + + try { + // Sever, kill the primary (destroying its state), and put a brand-new EMPTY PoppyDB + // on the SAME port - "a freshly restarted node" the follower will reconnect to. + rm.pauseReplicationForTest(); + Thread.sleep(500); + nodes.get(0).shutdown(); + nodes.remove(0); + startStandalonePrimary(port1); // fresh, empty, sequence starts near 0 + + rm.resumeReplicationForTest(); + + assertTrue(poll(30_000, () -> rm.getRefusedResyncCount() >= 1), + "reconnecting to a behind/empty primary must be refused (refusedResyncCount=" + + rm.getRefusedResyncCount() + ")"); + assertTrue(poll(5_000, rm::isRefusingDestructiveResync), + "the refusal state must be currently active"); + + // Data must be untouched - the whole point of the fix. + assertEquals(DOCS, localCount(), + "local data must survive a refused resync against a regressed primary"); + assertFalse(rm.isInitialSyncComplete(), + "a refused resync must not be reported as a completed sync"); + + List errors = appender.list.stream() + .filter(ev -> ev.getLevel() == Level.ERROR) + .collect(Collectors.toList()); + assertTrue(errors.stream().anyMatch(ev -> ev.getFormattedMessage().contains("refusing full re-sync") + && ev.getFormattedMessage().contains("possible restarted/stale primary")), + "an ERROR log must document the refusal: " + errors.stream() + .map(ILoggingEvent::getFormattedMessage).collect(Collectors.toList())); + + Map stats = rm.getStats(); + assertEquals(Boolean.TRUE, stats.get("refusingDestructiveResync"), + "getStats() must surface the active refusal"); + assertTrue((Long) stats.get("refusedResyncCount") >= 1, + "getStats() must surface the refusal count"); + + // Pacing sanity check (task-3 review, issue 1): while refusing, the watch + // register/teardown cycle must be paced (REFUSAL_WATCH_PACE_MS), not spinning hot. + // A ~6s window at the 2s pace should see roughly 3 registrations; assert well under a + // spin's ~1400/s rate (thousands in this window) without pinning to an exact count. + long registrationsBefore = registrationLogCount(appender); + long windowStart = System.currentTimeMillis(); + Thread.sleep(6_000); + long registrationsDuringWindow = registrationLogCount(appender) - registrationsBefore; + long windowMs = System.currentTimeMillis() - windowStart; + assertTrue(registrationsDuringWindow < 20, + "watch registrations while refusing must be paced, not spinning (got " + + registrationsDuringWindow + " registrations in " + windowMs + "ms)"); + assertTrue(rm.isRefusingDestructiveResync(), + "still refusing after the pacing-measurement window (primary was never caught up)"); + + // Recoverability (the brief's explicit requirement): once the SAME reconnected + // process genuinely catches up - its own sequence overtakes the follower's local + // sequence via real writes - the refusal must lift on its own and the follower must + // resync normally, with no operator intervention beyond writes actually happening. + PoppyDB caughtUpPrimary = nodes.get(0); + Morphium catchUpWriter = writerFor(port1, DB); + try { + writeDocs(catchUpWriter, (int) s + 50, "catchup"); + } finally { + catchUpWriter.close(); + } + + assertTrue(poll(60_000, () -> rm.isInitialSyncComplete() && !rm.isRefusingDestructiveResync()), + "the follower must eventually resync once the primary genuinely caught up " + + "(isInitialSyncComplete=" + rm.isInitialSyncComplete() + + ", refusing=" + rm.isRefusingDestructiveResync() + ")"); + assertTrue(poll(30_000, () -> localCount() == caughtUpPrimary.getDriver() + .count(DB, COLL, Doc.of(), null, null)), + "the recovered follower must converge to the caught-up primary's data (local=" + + localCount() + ")"); + + log.info("case (a) converged: local sequence was {}, refusedResyncCount={}", + s, rm.getRefusedResyncCount()); + } finally { + rmLogger.detachAppender(appender); + } + } + + // ---- case (b): reconnected primary is empty but caught up -> resync proceeds ------------ + + @Test + public void proceedsWhenReconnectedPrimaryIsEmptyButCaughtUp() throws Exception { + int port1 = nextPort(); + long s = bootstrapFollowerWithData(port1); + + rm.pauseReplicationForTest(); + Thread.sleep(500); + nodes.get(0).shutdown(); + nodes.remove(0); + PoppyDB freshPrimary = startStandalonePrimary(port1); + + // Legitimate post-dropDatabase shape: advance the fresh primary's own sequence counter + // well past the follower's local sequence (via unrelated writes), then drop that data - + // final state is empty, but the sequence keeps counting up, unlike a genuinely-behind + // primary. + Morphium bumpWriter = writerFor(port1, "bumpdb"); + try { + writeDocs(bumpWriter, (int) Math.max(50, s + 100), "bump"); + } finally { + bumpWriter.close(); + } + GenericCommand dropBump = new GenericCommand(freshPrimary.getDriver()); + dropBump.setDb("bumpdb"); + dropBump.setCmdData(Doc.of("dropDatabase", 1, "$db", "bumpdb")); + freshPrimary.getDriver().runCommand(dropBump); + + rm.resumeReplicationForTest(); + + assertTrue(poll(30_000, () -> rm.isInitialSyncComplete() && localCount() == 0), + "a legitimately caught-up (even if empty) primary must sync normally (got count=" + + localCount() + ", initialSyncComplete=" + rm.isInitialSyncComplete() + ")"); + assertEquals(0, rm.getRefusedResyncCount(), + "a caught-up primary must never trigger the destructive-resync refusal"); + assertFalse(rm.isRefusingDestructiveResync(), "must not be left in a refusing state"); + assertFalse(rm.wasLastSyncShortcut(), + "namespaces genuinely differed (primary emptied, local still had data) - must be a full sync"); + + log.info("case (b) converged: local sequence was {}", s); + } + + // ---- case (c) / D4: shortcut must not be taken when local has an extra namespace --------- + + @Test + public void shortcutNotTakenWhenLocalHasExtraNamespace() throws Exception { + int port1 = nextPort(); + long s = bootstrapFollowerWithData(port1); + PoppyDB primary = nodes.get(0); + + // Test-only backdoor: write straight into the follower's InMemoryDriver, bypassing + // replication, to create a namespace the (still perfectly healthy, never-restarted) + // primary does not have (same technique as FastResyncTest#fallbackOnDivergence, but a + // whole extra NAMESPACE rather than an extra document in an existing one). + GenericCommand inject = new GenericCommand(local); + inject.setDb("extradb"); + inject.setColl("extracoll"); + inject.setCmdData(Doc.of( + "insert", "extracoll", "$db", "extradb", + "documents", List.of(Doc.of("_id", "extra-doc", "note", "local-only")))); + local.runCommand(inject); + assertEquals(1, local.count("extradb", "extracoll", Doc.of(), null, null), + "the injected extra namespace must be present before the forced resync"); + + // Force a fresh sync cycle against the SAME still-alive primary (never restarted, so its + // sequence only ever increases - the D2 guard must never fire here, isolating this test + // to the shortcut's namespace comparison alone): sever, write a gap the shrunk buffer + // cannot cover, heal. + rm.pauseReplicationForTest(); + Thread.sleep(500); + Morphium writer = writerFor(port1, DB); + try { + writeDocs(writer, DOCS, "gap"); + } finally { + writer.close(); + } + Thread.sleep(300); + rm.resumeReplicationForTest(); + + assertTrue(poll(30_000, () -> rm.isInitialSyncComplete() && localCount() == 2 * DOCS), + "follower must converge to both batches after the forced resync (got count=" + + localCount() + ")"); + assertEquals(0, rm.getRefusedResyncCount(), + "the still-live, never-restarted primary must never trigger the D2 refusal"); + assertFalse(rm.wasLastSyncShortcut(), + "a follower with a local-only extra namespace must NOT take the consistency shortcut " + + "(union comparison, not intersection)"); + + // The extra namespace must be gone - proof the mismatch was actually detected and acted + // on, not silently waved through by an intersection-only comparison. + assertTrue(poll(15_000, () -> local.count("extradb", "extracoll", Doc.of(), null, null) == 0), + "the local-only extra namespace must be wiped by the (legitimate) full resync"); + + log.info("case (c) converged: local sequence was {}", s); + } + + // ---- issue 2 (task-3 review): sequence carry-over across RM replacement (leader change) -- + // + // PoppyDB#startReplicationToLeader constructs a brand-new ReplicationManager on every leader + // change, reusing the SAME persistent local driver (only the RM wrapper is replaced - the + // production analogue of what these two tests build by hand: rm1 against primary A is + // stop()ped, and a fresh rm is built against a different primary, sharing the same `local` + // driver). A fresh instance's own lastAppliedSequence starts at 0, which - absent the carry- + // over - would make the destructive-resync guard vacuously pass on every leader change; these + // tests exercise ReplicationManager#carryOverLastAppliedSequence directly, the same call + // PoppyDB now makes before starting the replacement. + // + // 2026-08-14 production-CI fix (I-2): both tests below now use the two-arg, + // primary-identity-aware carryOverLastAppliedSequence(seq, sourceAddress) - portA/portB/portC + // are all DIFFERENT addresses, exactly the shape a real leader change has in production. See + // carryOverRefusesOnlyWhenReplacementLeaderIsTheSameAddressRegressed below for the mirror that + // covers the SAME-address case (the actual kill chain). + + @Test + public void carryOverAllowsNormalSyncWhenReplacementLeaderIsCaughtUp() throws Exception { + int portA = nextPort(); + int portB = nextPort(); + startStandalonePrimary(portA); + + local = new InMemoryDriver(); + local.connect(); + ReplicationManager rm1 = new ReplicationManager(local, "localhost", portA); + extraReplicationManagers.add(rm1); + rm1.start(); + assertTrue(poll(30_000, rm1::isInitialSyncComplete), "rm1 initial sync must complete"); + + Morphium writerA = writerFor(portA, DB); + try { + writeDocs(writerA, DOCS, "pre"); + assertTrue(poll(30_000, () -> localCount() == DOCS), + "rm1 must live-replicate the batch (got " + localCount() + ")"); + } finally { + writerA.close(); + } + assertTrue(poll(5_000, () -> rm1.getLastAppliedSequence() > 0), + "rm1 lastAppliedSequence must have advanced past 0"); + long predecessorSeq = rm1.getLastAppliedSequence(); + String predecessorAddress = rm1.getLeaderAddress(); + + // Simulate PoppyDB#startReplicationToLeader tearing down the old RM on a leader change. + rm1.stop(); + + // The "new leader": a DIFFERENT standalone primary, fed enough writes that its own + // sequence is comfortably >= predecessorSeq - a legitimate, caught-up new leader. + startStandalonePrimary(portB); + Morphium writerB = writerFor(portB, DB); + try { + writeDocs(writerB, (int) predecessorSeq + 50, "leaderb"); + } finally { + writerB.close(); + } + + // Replacement RM, same local driver, carrying the predecessor's (sequence, source + // address) forward exactly as PoppyDB#startReplicationToLeader now does. Different + // address (portA vs portB) - per the identity-aware overload, this does NOT arm the + // guard; the outcome (sync succeeds) is unchanged from before I-2 either way here since + // the new leader is caught up regardless, but the MECHANISM is now adopt-at-registration, + // not a guard pass. + rm = new ReplicationManager(local, "localhost", portB); + rm.carryOverLastAppliedSequence(predecessorSeq, predecessorAddress); + rm.start(); + + assertTrue(poll(30_000, rm::isInitialSyncComplete), + "a caught-up replacement leader must sync normally despite the carried-over sequence"); + assertEquals(0, rm.getRefusedResyncCount(), + "a caught-up replacement leader must never trigger the destructive-resync refusal"); + assertFalse(rm.isRefusingDestructiveResync()); + + log.info("carry-over caught-up case converged: predecessorSeq={}, final local count={}", + predecessorSeq, localCount()); + } + + /** + * I-2 (production-CI fix, superseding the old {@code carryOverRefusesWhenReplacementLeaderIsRegressed}): + * a genuine leader change to a DIFFERENT primary, even one whose own counter is far below the + * predecessor's, must NOT be refused - the carried sequence lives in an unrelated, foreign + * number space and must not arm the guard at all. This is exactly the CI incident: node1 + * carried 227951 from its old leader; the new leader's own counter was 213896 (lower, but a + * completely different and entirely legitimate primary) - the old code refused for 40+ + * minutes; the fix adopts the new primary's own base at registration and lets dbHash/the + * consistency shortcut decide. Here that means a full resync legitimately proceeds (the new, + * near-empty primary's data does not match local's) and local converges to ITS (near-empty) + * state - the wipe is correct in this case, because a genuinely different, currently-elected + * leader's state is exactly what a follower is supposed to converge to. Protecting against a + * WRONGLY-elected empty leader is the election layer's job (Tasks 1/2/4), not this guard's - + * see the class-level javadoc on {@code ReplicationManager#carryOverLastAppliedSequence(long, String)}. + */ + @Test + public void carryOverAdoptsFreshBaseWhenReplacementLeaderIsDifferentEvenIfItsCounterIsLower() throws Exception { + int portA = nextPort(); + int portC = nextPort(); + startStandalonePrimary(portA); + + local = new InMemoryDriver(); + local.connect(); + ReplicationManager rm1 = new ReplicationManager(local, "localhost", portA); + extraReplicationManagers.add(rm1); + rm1.start(); + assertTrue(poll(30_000, rm1::isInitialSyncComplete), "rm1 initial sync must complete"); + + Morphium writerA = writerFor(portA, DB); + try { + writeDocs(writerA, DOCS, "pre"); + assertTrue(poll(30_000, () -> localCount() == DOCS), + "rm1 must live-replicate the batch (got " + localCount() + ")"); + } finally { + writerA.close(); + } + assertTrue(poll(5_000, () -> rm1.getLastAppliedSequence() > 0), + "rm1 lastAppliedSequence must have advanced past 0"); + long predecessorSeq = rm1.getLastAppliedSequence(); + String predecessorAddress = rm1.getLeaderAddress(); + + rm1.stop(); + + // The "new leader": a genuinely DIFFERENT (different port/address) standalone primary, + // fresh and empty - its own counter is near 0, far below predecessorSeq. In production + // this is an ordinary leader change to a new, currently-quiet leader, not a restart of + // the same node. + startStandalonePrimary(portC); + + rm = new ReplicationManager(local, "localhost", portC); + rm.carryOverLastAppliedSequence(predecessorSeq, predecessorAddress); + rm.start(); + + assertTrue(poll(30_000, rm::isInitialSyncComplete), + "a genuinely different replacement leader must sync normally - never blocked by a " + + "carried sequence earned against a different primary"); + assertEquals(0, rm.getRefusedResyncCount(), + "a different replacement leader must never trip the destructive-resync guard, " + + "regardless of its own counter being lower than the predecessor's"); + assertFalse(rm.isRefusingDestructiveResync()); + assertTrue(poll(10_000, () -> localCount() == 0), + "local must legitimately converge to the new (empty) leader's real state - this guard " + + "is not the barrier against a wrongly-elected leader, the election layer is"); + + log.info("I-2 different-leader-lower-counter case converged: predecessorSeq={}", predecessorSeq); + } + + /** + * I-2's mirror: the SAME leader address restarting empty/stale, reached via the + * RM-REPLACEMENT path (not the intra-RM {@code triggerResync()} path already covered by + * {@link #refusesWhenReconnectedPrimaryIsBehind()}) - this is the true kill chain + * {@code EmptyNodeRestartWipeTest} guards end-to-end, and must still refuse after I-2. + */ + @Test + public void carryOverRefusesOnlyWhenReplacementLeaderIsTheSameAddressRegressed() throws Exception { + int portA = nextPort(); + startStandalonePrimary(portA); + + local = new InMemoryDriver(); + local.connect(); + ReplicationManager rm1 = new ReplicationManager(local, "localhost", portA); + extraReplicationManagers.add(rm1); + rm1.start(); + assertTrue(poll(30_000, rm1::isInitialSyncComplete), "rm1 initial sync must complete"); + + Morphium writerA = writerFor(portA, DB); + try { + writeDocs(writerA, DOCS, "pre"); + assertTrue(poll(30_000, () -> localCount() == DOCS), + "rm1 must live-replicate the batch (got " + localCount() + ")"); + } finally { + writerA.close(); + } + assertTrue(poll(5_000, () -> rm1.getLastAppliedSequence() > 0), + "rm1 lastAppliedSequence must have advanced past 0"); + long predecessorSeq = rm1.getLastAppliedSequence(); + String predecessorAddress = rm1.getLeaderAddress(); + + rm1.stop(); + nodes.get(0).shutdown(); // kill the SAME node's process (destroying its in-memory state) + nodes.remove(0); + // ... and put a brand-new, empty PoppyDB back on the EXACT SAME port - "the same node + // restarted empty", reached this time via a fresh ReplicationManager (RM replacement), + // not via the original RM's own reconnect/triggerResync loop. + startStandalonePrimary(portA); + + rm = new ReplicationManager(local, "localhost", portA); + assertEquals(predecessorAddress, rm.getLeaderAddress(), + "test setup: the replacement RM must target the exact same address as the predecessor"); + rm.carryOverLastAppliedSequence(predecessorSeq, predecessorAddress); + rm.start(); + + assertTrue(poll(30_000, () -> rm.getRefusedResyncCount() >= 1), + "a same-address regressed replacement leader must still be refused (refusedResyncCount=" + + rm.getRefusedResyncCount() + ")"); + assertFalse(rm.isInitialSyncComplete(), "a refused replacement must not report a completed sync"); + assertEquals(DOCS, localCount(), + "local data carried over from the predecessor RM must survive a refused replacement resync"); + + log.info("I-2 same-address-regressed case converged: predecessorSeq={}, refusedResyncCount={}", + predecessorSeq, rm.getRefusedResyncCount()); + } + + // ---- issue 1 (2nd review pass): carry-over must survive a failed replication start -------- + // + // PoppyDB#startReplicationToLeader(String, long) is private and tightly coupled to the + // election/leader-discovery machinery (primary/leaderId guards, the retry-scheduler chain), + // and reproducing a genuine SYNCHRONOUS throw from newReplicationManager.start() realistically + // needs an auth/TLS connect mismatch (a plain unreachable port is documented elsewhere in this + // class as SWALLOWED by PooledDriver.connect(), not thrown - see + // scheduleReplicationLivenessProbe's javadoc). Driving that end-to-end through a real 3-node + // election, on a schedule precise enough to fail exactly the FIRST attempt and succeed the + // retry, would be disproportionate machinery for covering one fallback decision. Per the + // review's own escape hatch, these two tests instead exercise PoppyDB#carryOverSequenceFor - + // the pure decision function startReplicationToLeader delegates to - directly and in + // isolation: no network, no election, no started server at all. + + @Test + public void carryOverSequenceFallsBackToPersistedWatermarkWhenNoPredecessor() { + PoppyDB node = new PoppyDB(); + nodes.add(node); // shutdown() on a never-started instance is a safe no-op + + // The exact bug scenario: a PREVIOUS attempt persisted a real predecessor's position into + // the durable watermark, then (in production) newReplicationManager.start() threw, so + // replicationManager is null going into the retry - predecessor == null here mirrors that. + node.setLastKnownAppliedSequenceForTest(777); + + assertEquals(777, node.carryOverSequenceFor(null), + "a failed-start retry (predecessor == null) must fall back to the persisted " + + "watermark, not silently reset to 0"); + } + + @Test + public void carryOverSequenceReadsLivePredecessorWhenPresent() throws Exception { + PoppyDB node = new PoppyDB(); + nodes.add(node); + + // A stale watermark from an even earlier attempt must NOT shadow a real, live + // predecessor - the live value always wins when one is available. + node.setLastKnownAppliedSequenceForTest(1); + + InMemoryDriver drv = new InMemoryDriver(); + drv.connect(); + try { + ReplicationManager predecessor = new ReplicationManager(drv, "localhost", 1); + // Seeds lastAppliedSequence without ever calling start()/connecting anywhere - the + // decision function only reads getLastAppliedSequence(), so no live connection is + // needed to exercise it. + predecessor.carryOverLastAppliedSequence(500); + + assertEquals(500, node.carryOverSequenceFor(predecessor), + "a live predecessor's own position must be used, not the stale watermark"); + } finally { + drv.close(); + } + } + + // ---- I-2 (production-CI fix): PoppyDB#carryOverSourceFor, the companion to + // carryOverSequenceFor - same pure/isolated/no-network shape as the two tests above. + + @Test + public void carryOverSourceFallsBackToPersistedWatermarkWhenNoPredecessor() { + PoppyDB node = new PoppyDB(); + nodes.add(node); + + node.setLastKnownAppliedSequenceSourceForTest("localhost:9999"); + + assertEquals("localhost:9999", node.carryOverSourceFor(null), + "a failed-start retry (predecessor == null) must fall back to the persisted source " + + "watermark, exactly mirroring carryOverSequenceFor's own fallback"); + } + + @Test + public void carryOverSourceReadsLivePredecessorWhenPresent() throws Exception { + PoppyDB node = new PoppyDB(); + nodes.add(node); + + node.setLastKnownAppliedSequenceSourceForTest("localhost:1111"); // stale, must not shadow + + InMemoryDriver drv = new InMemoryDriver(); + drv.connect(); + try { + ReplicationManager predecessor = new ReplicationManager(drv, "localhost", 2222); + + assertEquals("localhost:2222", node.carryOverSourceFor(predecessor), + "a live predecessor's own leader address must be used, not the stale watermark"); + } finally { + drv.close(); + } + } + + // ---- I-1 (final review): adopt the synced primary's own base, don't max() with a stale one - + + /** + * Sequences are PRIMARY-LOCAL (see {@code tryConsistencyShortcut}'s own javadoc). Before this + * fix, a successful sync/shortcut reseeded {@code lastAppliedSequence} via + * {@code Math.max(current, lastKnownPrimarySequence)} - so a follower that had accumulated a + * HIGH sequence N against an old primary kept N even after successfully converging against a + * brand-new primary whose own counter is comfortably below N (only reachable via the + * consistency SHORTCUT: the D2 guard would otherwise refuse a full sync against a primary + * whose counter is behind local - so "successful sync with a lower primary counter" and + * "guard-gated full sync" are mutually exclusive by construction; the shortcut is the only + * path that bypasses the guard entirely). Every later reconnect then sent + * {@code resumeAfter=N}, which the new (low-counter) primary could never satisfy -> "resume + * window lost" -> a dbHash mismatch as soon as one real write happened (breaking the + * shortcut) -> the D2 guard comparing the new primary's still-low counter against the STALE + * inherited N -> refusing an entirely legitimate resync, unbounded on a quiet cluster. + * + *

    Reproduced here by CHURNING the original primary (delete + re-insert the same content) + * so its own counter inflates well past what recreating that exact content needs, then + * replacing it with a brand-new primary fed the identical content in a single write (same + * deterministic {@code _id}s via {@link #insertFixedDocs} - the dbHash comparison, unlike + * {@link #writeDocs}'s random {@code MorphiumId}s, needs byte-for-byte identical documents to + * match). Verified red against the reverted {@code Math.max(...)} before landing the + * {@code set(...)} fix (manual step, not committed - the assertion on + * {@code getLastAppliedSequence() < n} failed, and the final legitimate-resync poll timed out + * with the follower stuck refusing). + */ + @Test + public void adoptsNewPrimaryBaseAfterSuccessfulShortcutSoLaterResyncsAreNotRefused() throws Exception { + int port1 = nextPort(); + PoppyDB primaryA = startStandalonePrimary(port1); + + local = new InMemoryDriver(); + local.connect(); + rm = new ReplicationManager(local, "localhost", port1); + rm.start(); + assertTrue(poll(30_000, rm::isInitialSyncComplete), "initial (trivially empty) sync must complete"); + + insertFixedDocs(primaryA, DOCS, "chk"); + assertTrue(poll(30_000, () -> localCount() == DOCS), + "follower must live-replicate the batch (got " + localCount() + ")"); + + // Churn: delete and re-insert the SAME content so primaryA's own counter advances well + // past what a single write needs, while the final DATA (all dbHash compares) is + // unchanged. + GenericCommand delAll = new GenericCommand(primaryA.getDriver()); + delAll.setDb(DB); + delAll.setColl(COLL); + delAll.setCmdData(Doc.of("delete", COLL, "$db", DB, + "deletes", List.of(Doc.of("q", Doc.of(), "limit", 0)))); + primaryA.getDriver().runCommand(delAll); + assertTrue(poll(10_000, () -> primaryA.getDriver().count(DB, COLL, Doc.of(), null, null) == 0), + "churn delete must land on the primary"); + insertFixedDocs(primaryA, DOCS, "chk"); + + assertTrue(poll(30_000, () -> localCount() == DOCS), + "follower must reconverge to the re-inserted batch (got " + localCount() + ")"); + assertTrue(poll(5_000, () -> rm.getLastAppliedSequence() > 0), + "lastAppliedSequence must have advanced past 0"); + long n = rm.getLastAppliedSequence(); // the OLD primary's high, churn-inflated counter + + primaryA.getDriver().setChangeStreamHistoryLimit(2); + rm.pauseReplicationForTest(); + Thread.sleep(500); + nodes.get(0).shutdown(); + nodes.remove(0); + + // A brand-new primary whose own counter starts near 0, fed the EXACT SAME final content + // in one write (no churn), copied verbatim from the follower's current data (see + // copyLocalDataInto's javadoc for why a verbatim copy, not an independently-reconstructed + // insert, is what reliably dbHash-matches) - comfortably below N either way. + PoppyDB primaryB = startStandalonePrimary(port1); + copyLocalDataInto(primaryB); + + // isInitialSyncComplete() is ALREADY true at this point (stale from the trivial bootstrap + // sync at the very top of this test, never reset) - polling it directly would pass + // instantly without waiting for a real cycle against primaryB at all. Wait for the + // MONOTONIC shortcut-attempt counter to advance instead - the only reliable "a genuinely + // NEW sync decision cycle has run" signal (a boolean flip false->true here is real but + // racy: the whole reconnect+resume-window-lost+shortcut cycle can complete faster than a + // 100ms poll interval, so a poll might only ever observe the post-cycle `true`, identical + // to the pre-cycle stale `true`). + int shortcutAttemptsBeforeResume = rm.getConsistencyShortcutAttemptsForTest(); + rm.resumeReplicationForTest(); + + assertTrue(poll(30_000, () -> rm.getConsistencyShortcutAttemptsForTest() > shortcutAttemptsBeforeResume + && rm.isInitialSyncComplete()), + "a genuinely new sync cycle must run and complete against primaryB (shortcut attempts " + + "before=" + shortcutAttemptsBeforeResume + ", now=" + rm.getConsistencyShortcutAttemptsForTest() + + ", isInitialSyncComplete=" + rm.isInitialSyncComplete() + ")"); + assertTrue(rm.wasLastSyncShortcut(), + "test setup: this sync must take the consistency shortcut (identical data, D2 guard " + + "bypassed) to reproduce a successful sync while the new primary's own counter " + + "is far below N=" + n); + assertEquals(0, rm.getRefusedResyncCount(), "the initial shortcut sync itself must never be refused"); + + // The core I-1 assertion: lastAppliedSequence must have been ADOPTED from the new + // primary's own (low) base, not left at the old primary's inflated N via Math.max. + assertTrue(rm.getLastAppliedSequence() < n, + "lastAppliedSequence must adopt the new primary's own (lower) base after a successful " + + "sync, not stay pinned at the old primary's unrelated, inflated sequence space " + + "(N=" + n + ", got " + rm.getLastAppliedSequence() + ")"); + + // The actual regression: force a SECOND, entirely legitimate resync against the SAME + // still-alive (never regressed) primary B - a real gap it cannot buffer from. Before the + // fix this refused forever, because lastAppliedSequence (still pinned at N) could never + // be <= primary B's real, much lower counter. + primaryB.getDriver().setChangeStreamHistoryLimit(2); + rm.pauseReplicationForTest(); + Thread.sleep(500); + insertFixedDocs(primaryB, DOCS, "gap2"); + Thread.sleep(300); + rm.resumeReplicationForTest(); + + assertTrue(poll(30_000, () -> rm.isInitialSyncComplete() && localCount() == 2 * DOCS), + "a legitimate resync against the SAME (never-regressed) primary must proceed, not be " + + "refused forever due to a stale, unrelated old-primary sequence (got count=" + + localCount() + ", refusedResyncCount=" + rm.getRefusedResyncCount() + ")"); + assertEquals(0, rm.getRefusedResyncCount(), + "a legitimate resync must never trip the D2 guard once the base has been correctly adopted"); + + log.info("I-1 regression converged: N={}, final lastAppliedSequence={}", + n, rm.getLastAppliedSequence()); + } +} diff --git a/poppydb/src/test/java/de/caluga/poppydb/ReplicationStartRetryTest.java b/poppydb/src/test/java/de/caluga/poppydb/ReplicationStartRetryTest.java index f14ff3123..489800ceb 100644 --- a/poppydb/src/test/java/de/caluga/poppydb/ReplicationStartRetryTest.java +++ b/poppydb/src/test/java/de/caluga/poppydb/ReplicationStartRetryTest.java @@ -304,7 +304,13 @@ public void probeTearsDownNeverLiveReplicationManagerAndSchedulesRetry() throws ReplicationManager live = follower.getReplicationManagerForTest(); assertNotNull(live, "sanity: real replication is up before the probe runs"); - live.setWatchLiveForTest(false); // simulate the swallowed-connect-failure end state + // Simulate the swallowed-connect-failure end state: the watch is not live AND it never + // registered at all. Both matter - this RM really connected, so its watchGeneration is + // >= 1 and must be reset too, otherwise we'd be simulating a transient watch gap (which + // the probe must IGNORE, see probeNoOpsDuringTransientWatchGap below), not a + // never-came-up connection. + live.setWatchLiveForTest(false); + live.watchGeneration.set(0); follower.probeReplicationLiveness(leaderAddress, live); @@ -340,6 +346,31 @@ public void probeNoOpsWhenWatchIsLive() throws Exception { "a live probe target must be left running untouched"); } + /** + * 2026-08-06 review finding: {@code watchLive} routinely drops to false between two watch + * sessions (the watch loop's finally block) - a probe sampling exactly such a gap must NOT + * tear down a ReplicationManager whose connection did come up (watchGeneration >= 1 proves + * a watch registered at least once). Before the fix the probe checked the instantaneous + * {@code isWatchLive()} and this test fails with the RM torn down and replaced. + */ + @Test + public void probeNoOpsDuringTransientWatchGap() throws Exception { + int leaderPort = nextPort(); + int followerPort = nextPort(); + PoppyDB follower = startLeaderAndConnectedFollower("rsProbeGap", leaderPort, followerPort); + String leaderAddress = "localhost:" + leaderPort; + + ReplicationManager live = follower.getReplicationManagerForTest(); + assertTrue(poll(10_000, () -> live.watchGeneration.get() >= 1), + "sanity: the watch must have registered at least once against a real leader"); + live.setWatchLiveForTest(false); // transient gap: not live right now, but it WAS live + + follower.probeReplicationLiveness(leaderAddress, live); + + assertTrue(follower.getReplicationManagerForTest() == live, + "a transient watch gap must not get a healthy ReplicationManager torn down"); + } + /** * A probe firing after its target ReplicationManager was already superseded (a newer * leadership/discovery transition replaced it) must no-op - it must never tear down whatever diff --git a/poppydb/src/test/java/de/caluga/poppydb/UserReplicationTest.java b/poppydb/src/test/java/de/caluga/poppydb/UserReplicationTest.java index 92607bc4d..624533ae4 100644 --- a/poppydb/src/test/java/de/caluga/poppydb/UserReplicationTest.java +++ b/poppydb/src/test/java/de/caluga/poppydb/UserReplicationTest.java @@ -237,6 +237,42 @@ public void updateUserRotationReplicates() throws Exception { "after updateUser the secondary must accept the new password and reject the old one"); } + /** + * 2026-08-06 follow-up (dropUser): a user dropped on the primary must stop being loginable + * on the secondary - the drop replicates as a documentKey-keyed delete event through the + * same change stream the create/update path uses. + */ + @Test + public void dropUserReplicates() throws Exception { + int port1 = nextPort(); + int port2 = nextPort(); + PoppyDB primary = new PoppyDB(port1, "localhost", 20, 5); + PoppyDB secondary = new PoppyDB(port2, "localhost", 20, 5); + var hosts = List.of("localhost:" + port1, "localhost:" + port2); + var prio = Map.of("localhost:" + port1, 300, "localhost:" + port2, 100); + primary.configureReplicaSet("rsUserReplDrop", hosts, prio); + secondary.configureReplicaSet("rsUserReplDrop", hosts, prio); + + startServer(primary, port1); + waitForPrimary(primary); + startServer(secondary, port2); + waitForInitialSync(secondary); + + Map createReply = command(port1, Doc.of( + "createUser", "app3", "pwd", "droppw", "roles", List.of(), "$db", "admin")); + assertEquals(1.0, okOf(createReply), "createUser must succeed: " + createReply); + assertTrue(poll(30_000, () -> scramLoginWorks(port2, "app3", "droppw")), + "user must replicate to the secondary before the drop"); + + Map dropReply = command(port1, Doc.of("dropUser", "app3", "$db", "admin")); + assertEquals(1.0, okOf(dropReply), "dropUser on the primary must succeed: " + dropReply); + + assertTrue(poll(10_000, () -> !scramLoginWorks(port1, "app3", "droppw")), + "dropped user must stop being loginable on the primary itself"); + assertTrue(poll(30_000, () -> !scramLoginWorks(port2, "app3", "droppw")), + "dropped user must stop being loginable on the secondary once the delete replicated"); + } + @Test public void initialSyncCarriesUsers() throws Exception { int port1 = nextPort(); diff --git a/poppydb/src/test/java/de/caluga/poppydb/UserWritePrimaryOnlyTest.java b/poppydb/src/test/java/de/caluga/poppydb/UserWritePrimaryOnlyTest.java index 323e6474f..816677ac9 100644 --- a/poppydb/src/test/java/de/caluga/poppydb/UserWritePrimaryOnlyTest.java +++ b/poppydb/src/test/java/de/caluga/poppydb/UserWritePrimaryOnlyTest.java @@ -141,6 +141,12 @@ public void secondaryRejectsCreateAndUpdateUserWithNotWritablePrimary() throws E assertEquals(10107, codeOf(updateReply), "secondary must reject updateUser with NotWritablePrimary: " + updateReply); assertEquals("NotWritablePrimary", updateReply.get("codeName")); + + Map dropReply = command(sock, Doc.of( + "dropUser", "repltestuser", "$db", "admin")); + assertEquals(10107, codeOf(dropReply), + "secondary must reject dropUser with NotWritablePrimary: " + dropReply); + assertEquals("NotWritablePrimary", dropReply.get("codeName")); } } } diff --git a/poppydb/src/test/java/de/caluga/poppydb/netty/BuildVersionArrayTest.java b/poppydb/src/test/java/de/caluga/poppydb/netty/BuildVersionArrayTest.java new file mode 100644 index 000000000..ce9272e15 --- /dev/null +++ b/poppydb/src/test/java/de/caluga/poppydb/netty/BuildVersionArrayTest.java @@ -0,0 +1,33 @@ +package de.caluga.poppydb.netty; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** buildInfo.versionArray: mongorestore refuses servers reporting fewer than 3 entries. */ +@Tag("poppydb") +public class BuildVersionArrayTest { + + @Test + public void snapshotVersionParses() { + assertEquals(List.of(6, 3, 2, 0), MongoCommandHandler.buildVersionArray("6.3.2-SNAPSHOT")); + } + + @Test + public void releaseVersionParses() { + assertEquals(List.of(6, 3, 1, 0), MongoCommandHandler.buildVersionArray("6.3.1")); + } + + @Test + public void devFallbackStillHasFourEntries() { + assertEquals(List.of(0, 0, 0, 0), MongoCommandHandler.buildVersionArray("0.0.0-dev")); + } + + @Test + public void garbageYieldsZeros() { + assertEquals(List.of(0, 0, 0, 0), MongoCommandHandler.buildVersionArray("weird")); + } +} diff --git a/poppydb/src/test/java/de/caluga/poppydb/netty/FastPathOptionsTest.java b/poppydb/src/test/java/de/caluga/poppydb/netty/FastPathOptionsTest.java index cf88e4982..aaa0b2d56 100644 --- a/poppydb/src/test/java/de/caluga/poppydb/netty/FastPathOptionsTest.java +++ b/poppydb/src/test/java/de/caluga/poppydb/netty/FastPathOptionsTest.java @@ -120,6 +120,64 @@ public void updateDirect_honoursArrayFilters() throws Exception { "arrayFilters from the request must be passed through the fast path to the driver"); } + @Test + public void insertDirect_orderedStopReportsActualInsertCount() throws Exception { + MorphiumId x = new MorphiumId(); + List> batch = List.of( + Doc.of("_id", x, "n", 0), + Doc.of("_id", new MorphiumId(), "n", 1), + Doc.of("_id", x, "n", 2), // intra-batch duplicate -> ordered stop + Doc.of("_id", new MorphiumId(), "n", 3)); + + Map answer = handler().processInsertDirect(Doc.of( + "$db", db, "insert", coll, "documents", batch, "ordered", true)); + + assertEquals(2, countAll(), "ordered stops at the duplicate: only docs 0 and 1 are inserted"); + assertEquals(2, ((Number) answer.get("n")).intValue(), + "n must count actually inserted documents - the never-attempted tail after an ordered stop is not inserted"); + @SuppressWarnings("unchecked") + List> we = (List>) answer.get("writeErrors"); + assertEquals(2, ((Number) we.get(0).get("index")).intValue()); + } + + @Test + public void findDirect_honoursCollation() throws Exception { + List> seed = new ArrayList<>(); + seed.add(Doc.of("_id", new MorphiumId(), "name", "hello")); + new de.caluga.morphium.driver.commands.InsertMongoCommand(drv) + .setDb(db).setColl(coll).setDocuments(seed).execute(); + + Map answer = handler().processFindDirect(null, Doc.of( + "$db", db, "find", coll, "filter", Doc.of("name", "HELLO"), + "collation", Doc.of("locale", "en", "strength", 1)), 1); + + @SuppressWarnings("unchecked") + Map cursor = (Map) answer.get("cursor"); + @SuppressWarnings("unchecked") + List> firstBatch = (List>) cursor.get("firstBatch"); + assertEquals(1, firstBatch.size(), + "a case-insensitive collation from the request must be honoured by the find fast path"); + } + + @Test + public void findCursorRefill_keepsCollation() throws Exception { + List> seed = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + seed.add(Doc.of("_id", new MorphiumId(), "name", "hello", "n", i)); + } + new de.caluga.morphium.driver.commands.InsertMongoCommand(drv) + .setDb(db).setColl(coll).setDocuments(seed).execute(); + + FindCursorRegistry.FindCursorState state = new FindCursorRegistry.FindCursorState( + db, coll, Doc.of("name", "HELLO"), null, null, + Doc.of("locale", "en", "strength", 1), + new ArrayList<>(), 2, 0, false, 0); + handler().refillFindCursorWindow(state); + + assertEquals(5, state.remaining.size(), + "a getMore refill must re-execute the query with the original collation, not without it"); + } + @Test public void deleteDirect_honoursCollation() throws Exception { List> seed = new ArrayList<>(); diff --git a/poppydb/src/test/java/de/caluga/poppydb/netty/HelloCapabilitiesTest.java b/poppydb/src/test/java/de/caluga/poppydb/netty/HelloCapabilitiesTest.java new file mode 100644 index 000000000..646f122cb --- /dev/null +++ b/poppydb/src/test/java/de/caluga/poppydb/netty/HelloCapabilitiesTest.java @@ -0,0 +1,67 @@ +package de.caluga.poppydb.netty; + +import de.caluga.morphium.driver.inmem.InMemoryDriver; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * The hello reply advertises replica-set topology and logical sessions, which makes modern + * drivers enable retryable writes by default - a capability PoppyDB does not have (no + * (lsid, txnNumber) dedup, see the retryable-errors spec, issue #293). There is no standard + * hello field to say "sessions yes, retryable writes no", so PoppyDB publishes an explicit + * poppyCapabilities document clients and tooling can inspect. + */ +public class HelloCapabilitiesTest { + + private InMemoryDriver drv; + private MongoCommandHandler handler; + + @BeforeEach + public void setup() throws Exception { + drv = new InMemoryDriver(); + drv.connect(); + handler = new MongoCommandHandler(drv, null, null, null, new AtomicInteger(1), + "localhost", 17017, "rs0", List.of("localhost:17017"), true, "localhost:17017", + 0, () -> null); + } + + @AfterEach + public void tearDown() { + if (drv != null) { + drv.close(); + } + } + + @Test + public void helloAnswerKeepsIdentityFlags() { + Map answer = handler.helloAnswer(); + assertEquals(Boolean.TRUE, answer.get("poppyDB")); + assertEquals(Boolean.TRUE, answer.get("morphiumServer")); + assertEquals(Boolean.TRUE, answer.get("inMemoryBackend")); + assertNotNull(answer.get("logicalSessionTimeoutMinutes"), + "sessions stay advertised - the partial transaction support needs lsid"); + } + + @Test + public void helloAnswerCarriesHonestCapabilities() { + Map answer = handler.helloAnswer(); + @SuppressWarnings("unchecked") + Map caps = (Map) answer.get("poppyCapabilities"); + assertNotNull(caps, "hello must carry the poppyCapabilities document"); + assertEquals(Boolean.FALSE, caps.get("retryableWrites"), + "no (lsid, txnNumber) dedup exists - clients should run retryWrites=false"); + assertEquals(Boolean.FALSE, caps.get("journal"), "PoppyDB has no journal"); + assertEquals("snapshot", caps.get("durability")); + assertEquals("local", caps.get("readConcern")); + assertEquals("partial", caps.get("transactions")); + assertEquals("simplified", caps.get("textSearch")); + assertTrue(caps.get("version") instanceof Number); + } +} diff --git a/poppydb/src/test/java/de/caluga/poppydb/netty/JournalConcernHonestyTest.java b/poppydb/src/test/java/de/caluga/poppydb/netty/JournalConcernHonestyTest.java new file mode 100644 index 000000000..ed9b623a3 --- /dev/null +++ b/poppydb/src/test/java/de/caluga/poppydb/netty/JournalConcernHonestyTest.java @@ -0,0 +1,71 @@ +package de.caluga.poppydb.netty; + +import de.caluga.morphium.driver.Doc; +import de.caluga.morphium.driver.inmem.InMemoryDriver; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * PoppyDB has no journal. A write concern of j:true was silently accepted and acknowledged, + * promising durability that does not exist. Like mongod without journaling, the write is + * executed but the answer must carry a writeConcernError (code 2, BadValue). + */ +public class JournalConcernHonestyTest { + + private InMemoryDriver drv; + private MongoCommandHandler handler; + private final String db = "journal_test"; + private final String coll = "docs"; + + @BeforeEach + public void setup() throws Exception { + drv = new InMemoryDriver(); + drv.connect(); + handler = new MongoCommandHandler(drv, null, null, null, new AtomicInteger(1), + "localhost", 17017, "rs0", List.of("localhost:17017"), true, "localhost:17017", + 0, () -> null); + } + + @AfterEach + public void tearDown() { + if (drv != null) { + drv.close(); + } + } + + @Test + public void journalTrueYieldsWriteConcernError() { + Map answer = Doc.of("ok", 1.0, "n", 1); + boolean async = handler.postWrite(null, + Doc.of("$db", db, "insert", coll, "writeConcern", Doc.of("j", true)), + "insert", answer, 1); + + assertFalse(async, "j:true must not enter the async replication wait"); + @SuppressWarnings("unchecked") + Map wce = (Map) answer.get("writeConcernError"); + assertNotNull(wce, "j:true must be answered with a writeConcernError - PoppyDB has no journal"); + assertEquals(2, ((Number) wce.get("code")).intValue(), "mongod reports code 2 (BadValue) without journaling"); + assertEquals(1.0, ((Number) answer.get("ok")).doubleValue(), + "the write itself is executed - only the durability promise fails, like mongod without journaling"); + } + + @Test + public void journalFalseOrAbsentStaysClean() { + Map plain = Doc.of("ok", 1.0, "n", 1); + handler.postWrite(null, Doc.of("$db", db, "insert", coll), "insert", plain, 1); + assertNull(plain.get("writeConcernError")); + + Map jFalse = Doc.of("ok", 1.0, "n", 1); + handler.postWrite(null, + Doc.of("$db", db, "insert", coll, "writeConcern", Doc.of("j", false)), + "insert", jFalse, 1); + assertNull(jFalse.get("writeConcernError"), "j:false is satisfiable - memory acknowledgment needs no journal"); + } +} diff --git a/poppydb/src/test/java/de/caluga/poppydb/netty/ReplSetGetStatusDownPeerTest.java b/poppydb/src/test/java/de/caluga/poppydb/netty/ReplSetGetStatusDownPeerTest.java index c13c66404..8e3b54a5e 100644 --- a/poppydb/src/test/java/de/caluga/poppydb/netty/ReplSetGetStatusDownPeerTest.java +++ b/poppydb/src/test/java/de/caluga/poppydb/netty/ReplSetGetStatusDownPeerTest.java @@ -152,4 +152,58 @@ public void deadPeerReportsDownNotSecondaryOnceHeartbeatGoesStale() throws Excep assertThat(followerMember.get("stateStr")).isEqualTo("DOWN"); assertThat(followerMember.get("state")).isEqualTo(8); } + + /** + * 2026-08-06 review finding: {@code becomeLeader()} clears {@code peerLastContact}, and a + * missing entry used to mean "reachable, optimistically, forever" - so a peer that died + * BEFORE or WITH the leadership change (the classic crashed ex-primary after a failover) + * never acked a single heartbeat of the new leader and was reported SECONDARY for the rest + * of that leadership. With the grace-period fix, no-entry only counts as reachable within + * the freshness window measured from {@code leaderSince}; after that it must be DOWN. + * Reproduced with a 3-node RS whose third member is never started at all: the leader still + * wins the election (2/3 majority), the phantom never acks, and must show up DOWN once the + * grace period lapses - while the genuinely live follower stays SECONDARY. + */ + @Test + public void peerDeadSinceLeadershipChangeReportsDownAfterGracePeriod() throws Exception { + int port1 = nextPort(); + int port2 = nextPort(); + int port3 = nextPort(); // reserved but NEVER started - the "died with the failover" peer + ElectionConfig cfg = new ElectionConfig().setHeartbeatIntervalMs(100) + .setElectionTimeoutMinMs(300).setElectionTimeoutMaxMs(500); + PoppyDB leader = new PoppyDB(port1, "localhost", 20, 5); + PoppyDB follower = new PoppyDB(port2, "localhost", 20, 5); + var hosts = List.of("localhost:" + port1, "localhost:" + port2, "localhost:" + port3); + var prio = Map.of("localhost:" + port1, 100, "localhost:" + port2, 50, "localhost:" + port3, 10); + leader.configureReplicaSet("rsDeadSinceElection", hosts, prio, true, cfg); + follower.configureReplicaSet("rsDeadSinceElection", hosts, prio, true, cfg); + + startServer(leader, port1); + startServer(follower, port2); + waitForPrimary(leader); + + String phantomName = "localhost:" + port3; + String followerName = "localhost:" + port2; + + long deadline = System.currentTimeMillis() + 8000; + Map phantomMember = null; + while (System.currentTimeMillis() < deadline) { + Map status = command(port1, Doc.of("replSetGetStatus", 1, "$db", "admin")); + phantomMember = memberNamed(status, phantomName); + if ("DOWN".equals(phantomMember.get("stateStr"))) { + // The live follower must not have been dragged into DOWN by the same change. + assertThat(memberNamed(status, followerName).get("stateStr")) + .as("live follower must stay SECONDARY while the phantom goes DOWN") + .isEqualTo("SECONDARY"); + break; + } + Thread.sleep(200); + } + + assertThat(phantomMember) + .as("never-started peer never showed up as DOWN within 8s of the election") + .isNotNull(); + assertThat(phantomMember.get("stateStr")).isEqualTo("DOWN"); + assertThat(phantomMember.get("state")).isEqualTo(8); + } } diff --git a/poppydb/src/test/java/de/caluga/poppydb/netty/SecondaryReadPreferenceTest.java b/poppydb/src/test/java/de/caluga/poppydb/netty/SecondaryReadPreferenceTest.java new file mode 100644 index 000000000..4494452e9 --- /dev/null +++ b/poppydb/src/test/java/de/caluga/poppydb/netty/SecondaryReadPreferenceTest.java @@ -0,0 +1,89 @@ +package de.caluga.poppydb.netty; + +import de.caluga.morphium.driver.Doc; +import de.caluga.morphium.driver.inmem.InMemoryDriver; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.embedded.EmbeddedChannel; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * MongoDB's default read preference is primary. A read that reaches a secondary WITHOUT an + * explicit $readPreference must therefore be rejected (13435 NotPrimaryNoSecondaryOk), exactly + * like mongod treats a direct connection without secondaryOk. Previously only an explicit + * mode:"primary" was rejected - a preference-less read silently served possibly-stale data. + * Morphium's own wire commands always carry $readPreference (default primaryPreferred), so + * they are unaffected. + */ +public class SecondaryReadPreferenceTest { + + private InMemoryDriver drv; + + @BeforeEach + public void setup() throws Exception { + drv = new InMemoryDriver(); + drv.connect(); + } + + @AfterEach + public void tearDown() { + if (drv != null) { + drv.close(); + } + } + + private MongoCommandHandler handler(boolean primary) { + return new MongoCommandHandler(drv, null, null, null, new AtomicInteger(1), + "localhost", 17017, "rs0", List.of("localhost:17017", "localhost:17018"), + primary, "localhost:17018", 0, () -> null); + } + + private MongoCommandHandler.CheckResult dispatch(MongoCommandHandler h, Map doc) { + EmbeddedChannel ch = new EmbeddedChannel(h); + try { + ChannelHandlerContext hctx = ch.pipeline().context(MongoCommandHandler.class); + return h.preDispatch(hctx, "find", doc); + } finally { + ch.finishAndReleaseAll(); + } + } + + @Test + public void secondaryRejectsReadWithoutReadPreference() { + MongoCommandHandler.CheckResult res = dispatch(handler(false), + Doc.of("$db", "db", "find", "coll")); + assertTrue(res.rejected(), "no $readPreference means primary - a secondary must reject the read"); + assertEquals(13435, ((Number) res.errorResponse.get("code")).intValue()); + } + + @Test + public void secondaryRejectsExplicitPrimaryMode() { + MongoCommandHandler.CheckResult res = dispatch(handler(false), + Doc.of("$db", "db", "find", "coll", "$readPreference", Doc.of("mode", "primary"))); + assertTrue(res.rejected()); + assertEquals(13435, ((Number) res.errorResponse.get("code")).intValue()); + } + + @Test + public void secondaryAcceptsSecondaryCompatibleModes() { + for (String mode : List.of("primaryPreferred", "secondary", "secondaryPreferred", "nearest")) { + MongoCommandHandler.CheckResult res = dispatch(handler(false), + Doc.of("$db", "db", "find", "coll", "$readPreference", Doc.of("mode", mode))); + assertFalse(res.rejected(), "mode " + mode + " must be readable on a secondary"); + } + } + + @Test + public void primaryAcceptsReadWithoutReadPreference() { + MongoCommandHandler.CheckResult res = dispatch(handler(true), + Doc.of("$db", "db", "find", "coll")); + assertFalse(res.rejected()); + } +} diff --git a/poppydb/src/test/java/de/caluga/poppydb/netty/TransactionErrorPropagationTest.java b/poppydb/src/test/java/de/caluga/poppydb/netty/TransactionErrorPropagationTest.java new file mode 100644 index 000000000..298981c52 --- /dev/null +++ b/poppydb/src/test/java/de/caluga/poppydb/netty/TransactionErrorPropagationTest.java @@ -0,0 +1,92 @@ +package de.caluga.poppydb.netty; + +import de.caluga.morphium.driver.MorphiumTransactionContext; +import de.caluga.morphium.driver.inmem.InMemTransactionContext; +import de.caluga.morphium.driver.inmem.InMemoryDriver; +import io.netty.channel.ChannelHandlerContext; +import io.netty.channel.embedded.EmbeddedChannel; +import io.netty.util.AttributeKey; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * commitTransaction/abortTransaction answered ok:1 unconditionally - a commit that threw was + * only logged, the client believed its transaction was committed. Failures must surface as a + * mongo-shaped error response. + */ +public class TransactionErrorPropagationTest { + + private static final AttributeKey TX_KEY = AttributeKey.valueOf("txContext"); + + private InMemoryDriver drv; + private MongoCommandHandler handler; + private EmbeddedChannel channel; + private ChannelHandlerContext hctx; + + @BeforeEach + public void setup() throws Exception { + drv = new InMemoryDriver(); + drv.connect(); + handler = new MongoCommandHandler(drv, null, null, null, new AtomicInteger(1), + "localhost", 17017, "rs0", List.of("localhost:17017"), true, "localhost:17017", + 0, () -> null); + channel = new EmbeddedChannel(handler); + hctx = channel.pipeline().context(MongoCommandHandler.class); + } + + @AfterEach + public void tearDown() { + channel.finishAndReleaseAll(); + if (drv != null) { + drv.close(); + } + } + + @Test + public void commitFailureIsReportedToTheClient() { + // A touched-collections key without the db/collection separator makes the commit's + // merge loop throw - stands in for any internal commit failure. + InMemTransactionContext poisoned = new InMemTransactionContext(); + poisoned.setDatabase(new HashMap<>()); + poisoned.getTouchedCollections().add("no-separator-key"); + channel.attr(TX_KEY).set(poisoned); + + Map answer = handler.handleCommitTransaction(hctx); + + assertEquals(0.0, ((Number) answer.get("ok")).doubleValue(), + "a commit that threw must not be acknowledged with ok:1"); + assertNotNull(answer.get("errmsg"), "the client needs the failure reason"); + assertNotNull(answer.get("code"), "mongo-shaped errors carry a code"); + } + + @Test + public void successfulCommitStillAnswersOk() { + MorphiumTransactionContext tx = drv.startTransaction(false); + channel.attr(TX_KEY).set(tx); + + Map answer = handler.handleCommitTransaction(hctx); + + assertEquals(1.0, ((Number) answer.get("ok")).doubleValue()); + } + + @Test + public void commitWithoutTransactionStaysLenient() { + Map answer = handler.handleCommitTransaction(hctx); + assertEquals(1.0, ((Number) answer.get("ok")).doubleValue(), + "no-transaction commit stays a lenient no-op (full session state machine is out of scope)"); + } + + @Test + public void abortWithoutTransactionStaysLenient() { + Map answer = handler.handleAbortTransaction(hctx); + assertEquals(1.0, ((Number) answer.get("ok")).doubleValue()); + } +} diff --git a/poppydb/src/test/java/de/caluga/test/poppydb/LocalRsWriteProbe.java b/poppydb/src/test/java/de/caluga/test/poppydb/LocalRsWriteProbe.java new file mode 100644 index 000000000..00e8b2e21 --- /dev/null +++ b/poppydb/src/test/java/de/caluga/test/poppydb/LocalRsWriteProbe.java @@ -0,0 +1,125 @@ +package de.caluga.test.poppydb; + +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.MorphiumConfig; +import de.caluga.morphium.driver.wire.PooledDriver; +import de.caluga.poppydb.PoppyDB; +import de.caluga.test.mongo.suite.data.UncachedObject; + +/** + * Probe (temporary): write cost against a PoppyDB replica set vs a single node, both started + * IN THIS JVM on 127.0.0.1 - no network, no VPN, no shared test infrastructure. Compares + * individual store() against a batched storeList of the same document count. + */ +@Tag("manual") +public class LocalRsWriteProbe { + + private final Logger log = LoggerFactory.getLogger(LocalRsWriteProbe.class); + private static final int N = Integer.getInteger("probe.n", 300); + + @Test + public void probe() throws Exception { + PoppyDB s1 = new PoppyDB(16116, "127.0.0.1", 1000, 60); + PoppyDB s2 = new PoppyDB(16117, "127.0.0.1", 1000, 60); + PoppyDB s3 = new PoppyDB(16118, "127.0.0.1", 1000, 60); + PoppyDB single = new PoppyDB(16119, "127.0.0.1", 1000, 60); + var rs = List.of(s1, s2, s3); + + try { + for (var s : rs) { + s.configureReplicaSet("rs_probe", + List.of("127.0.0.1:16116", "127.0.0.1:16117", "127.0.0.1:16118"), null, true, null); + } + for (var s : rs) { + s.start(); + } + single.start(); + + AtomicReference primary = new AtomicReference<>(); + long deadline = System.currentTimeMillis() + 20000; + while (System.currentTimeMillis() < deadline && primary.get() == null) { + for (var s : rs) { + if (s.isPrimary()) { + primary.set(s); + } + } + Thread.sleep(100); + } + log.info("Primary: " + (primary.get() == null ? "KEINER" : primary.get().getPort())); + + measure("RS (3 Knoten, in-process)", List.of("127.0.0.1:16116", "127.0.0.1:16117", "127.0.0.1:16118")); + measure("Single (1 Knoten, in-process)", List.of("127.0.0.1:16119")); + } finally { + for (var s : rs) { + try { + s.shutdown(); + } catch (Exception ignored) { + } + } + try { + single.shutdown(); + } catch (Exception ignored) { + } + } + } + + private void measure(String label, List hosts) throws Exception { + MorphiumConfig cfg = new MorphiumConfig(); + cfg.connectionSettings().setDatabase("probe"); + cfg.driverSettings().setDriverName(PooledDriver.driverName); + for (String h : hosts) { + cfg.clusterSettings().addHostToSeed(h.split(":")[0], Integer.parseInt(h.split(":")[1])); + } + Morphium m = new Morphium(cfg); + + try { + m.dropCollection(UncachedObject.class, "p_single", null); + m.dropCollection(UncachedObject.class, "p_bulk", null); + Thread.sleep(300); + + List us = new ArrayList<>(N); + for (int i = 0; i < N; i++) { + UncachedObject o = new UncachedObject(); + o.setCounter(i); + o.setStrValue("v"); + long t0 = System.nanoTime(); + m.store(o, "p_single", null); + us.add((System.nanoTime() - t0) / 1000); + } + List sorted = new ArrayList<>(us); + Collections.sort(sorted); + long sum = 0; + for (long v : us) { + sum += v; + } + + List lst = new ArrayList<>(); + for (int i = 0; i < N; i++) { + UncachedObject o = new UncachedObject(); + o.setCounter(i); + o.setStrValue("v"); + lst.add(o); + } + long t0 = System.nanoTime(); + m.storeList(lst, "p_bulk"); + long bulkMs = (System.nanoTime() - t0) / 1_000_000; + + System.out.println(String.format( + "PROBE %-30s einzeln: avg=%.2f ms p50=%.2f p90=%.2f -> %.0f docs/s | storeList(%d): %d ms -> %.0f docs/s", + label, sum / 1000.0 / N, sorted.get(N / 2) / 1000.0, sorted.get((int)(N * 0.9)) / 1000.0, + N * 1_000_000.0 / sum, N, bulkMs, N * 1000.0 / Math.max(1, bulkMs))); + } finally { + m.close(); + } + } +} diff --git a/poppydb/src/test/java/de/caluga/test/poppydb/election/ElectionLogRecencyTest.java b/poppydb/src/test/java/de/caluga/test/poppydb/election/ElectionLogRecencyTest.java new file mode 100644 index 000000000..d0f6d0472 --- /dev/null +++ b/poppydb/src/test/java/de/caluga/test/poppydb/election/ElectionLogRecencyTest.java @@ -0,0 +1,250 @@ +package de.caluga.test.poppydb.election; + +import de.caluga.poppydb.election.*; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.function.BooleanSupplier; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * D1: the election log-recency check ({@link ElectionManager#handleVoteRequest}'s + * isLogAtLeastAsUpToDate comparison) must be fed by the real replication sequence instead of + * staying vacuously 0/0 on every node - see the bug this closes: a freshly restarted, empty + * node winning an election against nodes still holding data because {@code lastLogIndex} was + * never updated by any production caller. + * + *

    The deny-case tests deliberately set up the voter's data via the same production + * mechanism (leader-side {@code localSequenceSupplier} synced while heartbeating) rather than + * poking {@link ElectionManager#updateLogIndex} directly - that method already worked correctly + * before this fix (see {@code ElectionManagerTest#testVoteRequestLogComparison}); the bug was + * that nothing production ever called it. + * + *

    {@link #deniesVoteFromEmptyCandidateWithHigherStaleTermThanVoter} covers a second-round + * review finding: {@code isLogAtLeastAsUpToDate} must NOT fall back to comparing {@code + * lastLogTerm} when indices differ, because that term is only a {@code currentTerm} stand-in + * fed independently on each node - a once-elected, now-empty candidate can carry a higher stale + * term than a data-holding voter, and a term-first comparison would wrongly grant it the vote. + * + *

    {@link #updateLogIndexNeverLowersTheIndex} covers a third-round review finding: + * {@link ElectionManager#updateLogIndex} must use max (monotonic) semantics. Without it, a + * freshly-synced-then-silent node's seeded index (see {@code ReplicationManager}'s + * initial-sync-completion call site, and the dedicated integration test {@code + * InitialSyncElectionSeedTest}) would be silently regressed back to {@code 0} by the very next + * leader-side heartbeat tick (which reads the LOCAL driver's change-stream sequence - unrelated + * to, and possibly lower than, the synced position - see updateLogIndex's javadoc). + */ +public class ElectionLogRecencyTest { + + private static final Logger log = LoggerFactory.getLogger(ElectionLogRecencyTest.class); + + private final List managers = new ArrayList<>(); + + @AfterEach + void cleanup() { + for (ElectionManager manager : managers) { + try { + manager.stop(); + } catch (Exception e) { + // ignore + } + } + managers.clear(); + } + + /** Poll-based wait (no sleep+assert) matching the pattern used across the election suite. */ + private static void awaitCondition(String description, long timeoutMs, BooleanSupplier condition) throws InterruptedException { + long deadline = System.currentTimeMillis() + timeoutMs; + while (System.currentTimeMillis() < deadline) { + if (condition.getAsBoolean()) { + return; + } + Thread.sleep(10); + } + assertTrue(condition.getAsBoolean(), "Timed out waiting for: " + description); + } + + /** + * Single-node cluster that auto-elects itself leader, with its localSequenceSupplier wired + * to a fixed "real replication sequence" - exactly the supplier PoppyDB wires to + * {@code driver::getChangeStreamSequence} in production. Waits for that sequence to actually + * show up in {@link ElectionManager#getLastLogIndex()} through whatever production feeds it. + */ + private ElectionManager singleNodeLeaderWithSequence(String address, long sequence) throws InterruptedException { + ElectionConfig config = new ElectionConfig() + .setElectionTimeoutMinMs(50) + .setElectionTimeoutMaxMs(100); + ElectionManager manager = new ElectionManager(address, List.of(address), config); + managers.add(manager); + manager.setLocalSequenceSupplier(() -> sequence); + + CountDownLatch leaderLatch = new CountDownLatch(1); + manager.setOnLeadershipChange(isLeader -> { + if (isLeader) { + leaderLatch.countDown(); + } + }); + manager.start(); + assertTrue(leaderLatch.await(2, TimeUnit.SECONDS), address + " should have become leader (single node)"); + + awaitCondition(address + " lastLogIndex synced to real sequence " + sequence, 2000, + () -> manager.getLastLogIndex() == sequence); + return manager; + } + + @Test + void deniesVoteFromEmptyCandidateWhenVoterHoldsData() throws Exception { + ElectionManager voter = singleNodeLeaderWithSequence("voter-with-data:27017", 500); + + // An empty (freshly restarted) candidate: log index/term both 0, but a higher election + // term than the voter - this is exactly how the real bug won: the empty node out-races + // the data-holding node's term through repeated candidacy retries, forcing the voter to + // adopt the higher term before the log check runs. + VoteRequest emptyCandidateRequest = new VoteRequest( + voter.getCurrentTerm() + 1, "empty-candidate:27017", 0, 0); + VoteResponse response = voter.handleVoteRequest(emptyCandidateRequest); + + assertFalse(response.isVoteGranted(), + "must deny vote to an empty candidate (log behind) when the voter holds real replicated data"); + } + + @Test + void deniesVoteFromEmptyCandidateWithHigherStaleTermThanVoter() throws Exception { + ElectionManager voter = singleNodeLeaderWithSequence("voter-with-data:27020", 500); + + // Adversarial case from review: an empty candidate (index 0) whose lastLogTerm happens + // to be HIGHER than the voter's currentTerm - e.g. it was elected once before while + // still empty, or simply raced its own currentTerm up through repeated candidacy + // retries. A term-first Raft-style comparison would grant this vote (candidateLastTerm > + // myLastTerm), reopening the exact empty-node-wipe bug. Must still be denied on index + // alone. + VoteRequest staleHighTermEmptyCandidateRequest = new VoteRequest( + voter.getCurrentTerm() + 1, "empty-candidate-high-term:27020", 0, voter.getCurrentTerm() + 100); + VoteResponse response = voter.handleVoteRequest(staleHighTermEmptyCandidateRequest); + + assertFalse(response.isVoteGranted(), + "must deny an empty candidate even when its (stand-in) lastLogTerm is higher than the voter's"); + } + + @Test + void grantsVoteFromCaughtUpCandidateEvenWhenVoterIsEmpty() throws Exception { + ElectionConfig config = new ElectionConfig() + .setElectionTimeoutMinMs(1000) + .setElectionTimeoutMaxMs(2000); + ElectionManager voter = new ElectionManager("empty-voter:27018", List.of("empty-voter:27018", "candidate:27018"), config); + managers.add(voter); + voter.start(); + + VoteRequest caughtUpCandidateRequest = new VoteRequest( + voter.getCurrentTerm() + 1, "candidate:27018", 500, 0); + VoteResponse response = voter.handleVoteRequest(caughtUpCandidateRequest); + + assertTrue(response.isVoteGranted(), + "must grant vote to a candidate that is at least as up to date, even from an empty voter"); + } + + @Test + void voterSeededViaUpdateLogIndexDeniesEmptyCandidate() throws Exception { + // Direct-call variant (as opposed to deniesVoteFromEmptyCandidateWhenVoterHoldsData's + // production-wiring variant): confirms the seed-then-deny path also works when fed the + // way ReplicationManager's initial-sync-completion call site feeds it - a single + // updateLogIndex() call with no further live events. + ElectionConfig config = new ElectionConfig() + .setElectionTimeoutMinMs(60_000) + .setElectionTimeoutMaxMs(60_000); + ElectionManager voter = new ElectionManager("seeded-voter:27021", List.of("seeded-voter:27021", "candidate:27021"), config); + managers.add(voter); + voter.updateLogIndex(500, 0); + voter.start(); + + VoteRequest emptyCandidateRequest = new VoteRequest( + voter.getCurrentTerm() + 1, "empty-candidate:27021", 0, 0); + VoteResponse response = voter.handleVoteRequest(emptyCandidateRequest); + + assertFalse(response.isVoteGranted(), + "a voter seeded via updateLogIndex (e.g. after an initial sync) must deny an empty candidate"); + } + + @Test + void updateLogIndexNeverLowersTheIndex() throws Exception { + ElectionConfig config = new ElectionConfig() + .setElectionTimeoutMinMs(60_000) + .setElectionTimeoutMaxMs(60_000); + ElectionManager manager = new ElectionManager("monotonic:27022", List.of("monotonic:27022"), config); + managers.add(manager); + + manager.updateLogIndex(500, 3); + assertEquals(500, manager.getLastLogIndex(), "index must be set on the first call"); + + // Simulates the leader-side heartbeat feed reading the local driver's change-stream + // sequence (0, since initial sync runs under suppressChangeStreamEvents) right after the + // seed above - must not regress the already-known, higher index. + manager.updateLogIndex(0, 3); + assertEquals(500, manager.getLastLogIndex(), + "a lower index must never overwrite a higher one already recorded"); + + // A genuinely higher index must still win. + manager.updateLogIndex(600, 3); + assertEquals(600, manager.getLastLogIndex(), "a higher index must still advance the value"); + } + + @Test + void emptyNodeWithDataBearingPeerDelaysCandidacyUntilSyncedOrPeerNeverSeen() throws Exception { + // D3: candidacy restraint. Short timeouts so several election-timeout cycles fit into + // the sleep window below - if the guard did not hold, at least one of them would flip + // this node to CANDIDATE. + ElectionConfig config = new ElectionConfig() + .setElectionTimeoutMinMs(50) + .setElectionTimeoutMaxMs(100); + ElectionManager restrained = new ElectionManager("restrained:27023", + List.of("restrained:27023", "data-peer:27023"), config); + managers.add(restrained); + restrained.start(); + + // Simulate this node observing AppendEntries traffic from a leader that reports a real, + // non-zero log index - exactly what handleAppendEntries sees in production heartbeats. + AppendEntriesRequest fromDataPeer = AppendEntriesRequest.heartbeat( + restrained.getCurrentTerm(), "data-peer:27023", 500, 0, 500); + restrained.handleAppendEntries(fromDataPeer); + + // Own index is still 0 (nothing applied/produced this process lifetime). Despite + // repeated election timeouts, this node must never transition to CANDIDATE while a + // data-bearing peer is known - it can only lose that election and would just inflate + // the term, forcing the legitimate leader into a pointless step-down. + Thread.sleep(600); + assertEquals(ElectionState.FOLLOWER, restrained.getState(), + "empty node must hold back candidacy while a data-bearing peer is known, not race to CANDIDATE"); + + // Once its own index catches up (sync completed - Task 1's seed makes this prompt), the + // guard must no longer apply and candidacy becomes eligible again on the very next timeout. + restrained.updateLogIndex(10, restrained.getCurrentTerm()); + awaitCondition("restrained becomes CANDIDATE once its own index is no longer 0", 1000, + () -> restrained.getState() == ElectionState.CANDIDATE); + } + + @Test + void coldStartGrantsVoteWhenBothSidesAreEmpty() throws Exception { + // Cold-start invariant: three freshly started nodes, all at log index 0, must still be + // able to elect a leader - equal (0 == 0) indices must GRANT, not deadlock forever. + ElectionConfig config = new ElectionConfig() + .setElectionTimeoutMinMs(1000) + .setElectionTimeoutMaxMs(2000); + ElectionManager voter = new ElectionManager("cold-voter:27019", List.of("cold-voter:27019", "cold-candidate:27019"), config); + managers.add(voter); + voter.start(); + + VoteRequest emptyCandidateRequest = new VoteRequest( + voter.getCurrentTerm() + 1, "cold-candidate:27019", 0, 0); + VoteResponse response = voter.handleVoteRequest(emptyCandidateRequest); + + assertTrue(response.isVoteGranted(), + "three empty nodes at cold start must still be able to elect a leader"); + } +} diff --git a/poppydb/src/test/java/de/caluga/test/poppydb/election/ElectionManagerTest.java b/poppydb/src/test/java/de/caluga/test/poppydb/election/ElectionManagerTest.java index f81e0db21..b2ea8b8b6 100644 --- a/poppydb/src/test/java/de/caluga/test/poppydb/election/ElectionManagerTest.java +++ b/poppydb/src/test/java/de/caluga/test/poppydb/election/ElectionManagerTest.java @@ -12,6 +12,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; import static org.junit.jupiter.api.Assertions.*; @@ -149,6 +150,112 @@ void testVoteRequestDeniedAlreadyVoted() throws Exception { assertFalse(response2.isVoteGranted(), "Second vote in same term should be denied"); } + @Test + void testPriorityDenialDoesNotStarveOwnElectionTimer() throws Exception { + log.info("Testing that repeated priority-denied vote requests don't push back our own candidacy"); + + // Reproduces a real 42s (vs. the ~8s typical) election observed on poppydb.fritz.box + // during the 6.3.0 pre-release full suite run: a lower-priority node's timeout fired + // first and it kept retrying with a new term every ~8s; each retry - though correctly + // denied here on priority grounds - was resetting the denier's own election timer, + // repeatedly deferring the very candidacy the priority check exists to protect. + // + // Design note (2026-08-07, after THREE flaky iterations of this test - see git history): + // any shape that OBSERVES the CANDIDATE state is inherently racy, because under a + // continuous barrage CANDIDATE is a transient state by design: becomeCandidate() bumps + // our term to k+1, and the barrage's next-but-one request (term k+2 > k+1) legitimately + // knocks us back to FOLLOWER via the higher-term becomeFollower() path within one or two + // sender periods. Polling getState() - or re-asserting it after the poll - races that + // ~30-60ms window (attempts 1 and 2 died of exactly this). Attempt 3's measured 2/20 + // local flake was subtler still: its "deniedCount > 5" sanity check raced the fix + // WORKING - on a low timeout draw the node turned candidate after exactly 5 denials, + // the poll stopped the sender, and the sanity check failed the test even though the + // election behavior was perfect (surefire logs show "became CANDIDATE at term 6" at + // ~0.19s in both failures). + // + // So: don't observe the state at all. The invariant is "the node STARTS ITS OWN + // ELECTION while denials are still arriving", and starting an election has a positive, + // latching, production-visible signal - becomeCandidate() calls requestVotes(), which + // invokes the sendVoteRequest callback for every peer. Counting down a latch there + // cannot be un-rung by the (correct) subsequent demotion, needs no poll, and needs no + // tuned window: with the fix it fires ~one election timeout after start(); with the bug + // the barrage (30ms cadence, far below the ~325ms minimum effective timeout) resets the + // timer forever and the latch deterministically never fires within the generous await. + ElectionConfig config = new ElectionConfig() + .setElectionTimeoutMinMs(300) + .setElectionTimeoutMaxMs(400) + .setElectionPriority(75); // effective timeout ~325-425ms (priority adds 25ms) + + List hosts = List.of("localhost:27017", "localhost:27018", "localhost:27019"); + ElectionManager manager = new ElectionManager("localhost:27017", hosts, config); + managers.add(manager); + + AtomicInteger deniedCount = new AtomicInteger(0); + AtomicInteger grantedCount = new AtomicInteger(0); + + // Fires (once per peer) inside becomeCandidate() -> requestVotes(): the node has + // started its own election. Capture the denial count and state as they were at that + // exact moment (the callback runs under the state lock, so state is stably CANDIDATE + // here) - asserting on live state afterwards would race the barrage-driven demotion. + CountDownLatch candidacyLatch = new CountDownLatch(1); + AtomicInteger denialsAtCandidacy = new AtomicInteger(-1); + AtomicReference stateAtCandidacy = new AtomicReference<>(); + manager.setSendVoteRequest((peer, req) -> { + denialsAtCandidacy.compareAndSet(-1, deniedCount.get()); + stateAtCandidacy.compareAndSet(null, manager.getState()); + candidacyLatch.countDown(); + }); + + manager.start(); + assertEquals(ElectionState.FOLLOWER, manager.getState()); + + // A lower-priority peer (localhost:27019) repeatedly starts a new election, term by + // term, every 30ms - continuously, much faster than our own election timeout, for the + // whole duration of the await below. Before the fix, each denied request still reset + // our timer via becomeFollower(), so this barrage postponed our own candidacy for as + // long as it kept arriving. + AtomicBoolean keepDenying = new AtomicBoolean(true); + Thread denialSender = new Thread(() -> { + int term = 1; + while (keepDenying.get()) { + try { + VoteRequest request = new VoteRequest(term++, "localhost:27019", 0, 0, 50); + VoteResponse response = manager.handleVoteRequest(request); + if (response.isVoteGranted()) { + grantedCount.incrementAndGet(); + } else { + deniedCount.incrementAndGet(); + } + Thread.sleep(30); + } catch (InterruptedException e) { + return; + } + } + }, "denial-sender"); + denialSender.start(); + boolean becameCandidate; + try { + becameCandidate = candidacyLatch.await(5, TimeUnit.SECONDS); + } finally { + keepDenying.set(false); + denialSender.join(2000); + } + + assertTrue(becameCandidate, + "Node should have started its own election despite continuous lower-priority vote requests"); + assertEquals(ElectionState.CANDIDATE, stateAtCandidacy.get(), + "vote requests must have been sent from CANDIDATE state"); + // Sanity: the barrage was actually in flight BEFORE candidacy - otherwise this test + // would pass for the wrong reason (e.g. a broken sender thread, or denials so sparse + // the timer was never contested). >= 3 leaves ample slack: at a 30ms cadence against a + // >= 325ms effective timeout, ~10 denials are expected before the timer first fires. + assertTrue(denialsAtCandidacy.get() >= 3, + "expected several denied vote requests before candidacy, got " + denialsAtCandidacy.get()); + // Our higher-priority node must never actually vote for the lower-priority candidate. + assertEquals(0, grantedCount.get(), + "no vote must ever be granted to the lower-priority candidate"); + } + @Test void testLeaderDiscoveryFiresOnFirstHeartbeat() throws Exception { log.info("Testing onLeaderDiscovered fires on first heartbeat (and only on change)"); @@ -297,21 +404,26 @@ void testVoteRequestLogComparison() throws Exception { ElectionManager manager = new ElectionManager("localhost:27017", hosts, config); managers.add(manager); - // Set our log state to be ahead + // Set our log state to reflect real (non-empty) replication progress. manager.updateLogIndex(10, 2); manager.start(); Thread.sleep(50); - // Request from candidate with older log (lower term) - VoteRequest oldLogRequest = new VoteRequest(3, "localhost:27018", 5, 1); - VoteResponse response1 = manager.handleVoteRequest(oldLogRequest); - assertFalse(response1.isVoteGranted(), "Should deny vote to candidate with older log (lower term)"); - - // Request from candidate with up-to-date log - VoteRequest upToDateRequest = new VoteRequest(4, "localhost:27019", 10, 2); - VoteResponse response2 = manager.handleVoteRequest(upToDateRequest); - assertTrue(response2.isVoteGranted(), "Should grant vote to candidate with up-to-date log"); + // isLogAtLeastAsUpToDate is deliberately NOT a Raft term/index comparison (see its + // javadoc): replication sequences are primary-local and lastLogTerm is only a + // currentTerm stand-in, so term ordering across nodes isn't meaningful here. The one + // invariant it enforces: an empty candidate (index 0) must never win against a voter + // that holds data (index > 0) - a stale-but-non-empty candidate is intentionally NOT + // denied by this check (handled elsewhere: fail-closed resync + candidacy restraint). + VoteRequest emptyCandidateRequest = new VoteRequest(3, "localhost:27018", 0, 0); + VoteResponse response1 = manager.handleVoteRequest(emptyCandidateRequest); + assertFalse(response1.isVoteGranted(), "Should deny vote to an empty candidate (index 0) when we hold data"); + + // Any non-zero index is granted, even if numerically behind our own index. + VoteRequest nonEmptyCandidateRequest = new VoteRequest(4, "localhost:27019", 5, 1); + VoteResponse response2 = manager.handleVoteRequest(nonEmptyCandidateRequest); + assertTrue(response2.isVoteGranted(), "Should grant vote to any non-empty candidate"); } @Test diff --git a/quarkus-morphium/CHANGELOG.md b/quarkus-morphium/CHANGELOG.md new file mode 100644 index 000000000..8753561a8 --- /dev/null +++ b/quarkus-morphium/CHANGELOG.md @@ -0,0 +1,122 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Changed + +#### Integrated into the Morphium reactor as an optional module +`quarkus-morphium` moves from a standalone repository under the `io.quarkiverse.morphium` +groupId into `sboesebeck/morphium` as an optional, four-submodule (`runtime`, +`deployment`, `testing`, `integration-tests`) reactor module under the `de.caluga` +groupId. Maven coordinates change from `io.quarkiverse.morphium:quarkus-morphium:1.2.0` +to `de.caluga:quarkus-morphium:${morphium.version}` (currently `6.3.0-SNAPSHOT`); no +package renames, no API changes. The extension now builds and releases in lockstep +with the Morphium core it depends on, instead of tracking a separately-versioned +Morphium release. The Morphium core has zero compile- or runtime dependency on this +module — building the reactor with `-DskipExtensions` produces an unchanged core. +Distribution moves from the interim GitHub Packages registry to Morphium's regular +Maven Central release pipeline; the standalone repository's own CI workflow, issue +templates, and PR template are retired in favor of the main repository's. + +## [1.2.0] + +### Added +- **Default `MorphiumId` JSON serialization** – the extension now ships a Jackson + `ObjectMapperCustomizer` and a JSON-B `JsonbConfigCustomizer` that (de)serialize + `de.caluga.morphium.driver.MorphiumId` as its canonical 24-character hex string in + both directions. Outgoing entities with `@Id MorphiumId id` emit `"id":""`, and + REST endpoints accepting a `MorphiumId` path/query/body parameter parse the hex string + back into a real `MorphiumId`. No user-written serializer is required. Registration is + automatic and gated on the JSON layer actually present on the classpath (`quarkus-jackson` + and/or `quarkus-jsonb`, both optional dependencies); apps that emit no JSON are unaffected. + +### Changed +- **BREAKING (positive):** `MorphiumId` is now serialized as a hex string by default + instead of the internal bean shape `{"pid":..,"counter":..,"machineId":..,"bytes":"..","time":..}`. + Consumers that explicitly parsed the old struct must update their clients; none should — + the old form was unusable as an id key. + + **Motivating incident:** in the datona-component-library showcase + (`/components/tables/column-types`), the bean-walked struct made frontend grids + (AG-Grid, MUI DataGrid) call `String(row.id)` and receive the literal `"[object Object]"`. + Every row collapsed to the same key, the grid lost row identity and re-rendered every + cell on each change-detection tick — flicker, lost focus, runaway memory growth, and + an eventual renderer-process crash (Chromium exit code 5). The consumer-side fix was a + one-line serializer; shipping it in the extension prevents every consumer from hitting + the same bug. The hex string is the canonical, only public wire form of an id. + +### Added (CosmosDB) +- **CosmosDB graceful degradation** – `@MorphiumTransactional` interceptor auto-detects + Azure CosmosDB via Morphium's `isCosmosDB()` driver API and skips transaction wrapping; + individual operations remain atomic, only multi-document rollback is unavailable +- Detection cached at startup via `@PostConstruct`; defensive fallback catches + `UnsupportedOperationException` from `startTransaction()` if detection was missed +- Uses JBoss Logging (Quarkus idiomatic) instead of SLF4J +- Added "CosmosDB Compatibility" section to `transactions.adoc` + +### Changed +- **BREAKING:** Config prefix changed from `morphium.*` to `quarkus.morphium.*` to follow + Quarkus extension conventions. Rename all `morphium.` properties in your + `application.properties` to `quarkus.morphium.` (e.g. `morphium.database` becomes + `quarkus.morphium.database`). Dev Services keys (`quarkus.morphium.devservices.*`) are + unchanged. +- LICENSE copyright updated from `Bardioc1977` to `The Quarkiverse Authors` + +### Added +- **SSL/TLS configuration** – `quarkus.morphium.ssl.*` properties for encrypted connections, + X.509 client-certificate authentication, keystore/truststore paths, and hostname verification +- **Health checks** – MicroProfile liveness (`/q/health/live`), readiness (`/q/health/ready`), + and startup (`/q/health/started`) probes registered automatically via SmallRye Health; + readiness includes connection pool metadata (connectionsInUse, threadsWaiting, per-host counts); + disable with `quarkus.morphium.health.enabled=false` +- **Blocking call detector** – `MorphiumBlockingCallDetector` warns (throttled to 30s intervals) + when Morphium write operations are called from Vert.x event-loop threads; suggests + `@RunOnVirtualThread` or `@Blocking` as fix +- **Dev Services replica-set mode** – `quarkus.morphium.devservices.replica-set=true` starts + MongoDB as a single-node replica set via `MongoDBContainer`, enabling multi-document + transactions in dev/test mode +- **Dev UI card** – displays MongoDB connection info (hosts, database, mode, container ID, + status) in the Quarkus Dev UI at `/q/dev-ui/` +- **Hot-reload entity cache clearing** – `ObjectMapperImpl.clearEntityCache()` called on + Morphium creation to avoid stale class references after Quarkus live reload +- **Antora documentation** – comprehensive multi-page documentation site (9 pages) with + GitHub Pages deployment via GitHub Actions workflow +- GitHub Packages Maven registry for artifact distribution (interim until Maven Central) +- SNAPSHOT auto-deploy on push to main +- Apache 2.0 copyright headers in all Java source files +- POM metadata: ``, ``, ``, ``, `` +- Governance files: `CONTRIBUTING.md`, `CODE_OF_CONDUCT.md`, `SECURITY.md` +- GitHub templates: issue templates, PR template, `CODEOWNERS`, `dependabot.yml` +- `.editorconfig` for consistent code style +- `keywords` metadata in `quarkus-extension.yaml` +- `@MorphiumTransactional` CDI interceptor for declarative transaction management – + automatically calls `startTransaction()` / `commitTransaction()` / `abortTransaction()` +- Transaction lifecycle CDI events (`MorphiumTransactionEvent`) with `@MorphiumTxPhase` qualifier: + `BEFORE_COMMIT`, `AFTER_COMMIT`, `AFTER_ROLLBACK` (includes the causing exception) +- Initial implementation of the Quarkus Morphium extension +- `@ApplicationScoped` CDI producer for `Morphium` via `MorphiumProducer` +- Type-safe runtime configuration via `@ConfigMapping(prefix = "quarkus.morphium")`: + - `quarkus.morphium.hosts` – MongoDB host list (default: `localhost:27017`) + - `quarkus.morphium.database` – target database name + - `quarkus.morphium.username` / `quarkus.morphium.password` – optional credentials + - `quarkus.morphium.auth-database` – authentication database (default: `admin`) + - `quarkus.morphium.read-preference` – read preference (default: `primary`) + - `quarkus.morphium.create-indexes` – automatic index creation on startup (default: `true`) + - `quarkus.morphium.max-connections` – connection pool size (default: `250`) + - `quarkus.morphium.atlas-url` – optional MongoDB Atlas connection string (overrides `hosts`) + - `quarkus.morphium.driver-name` – Morphium driver (default: `PooledDriver`; use `InMemDriver` for tests) + - `quarkus.morphium.cache.global-valid-time` – query cache TTL in ms (default: `60000`) + - `quarkus.morphium.cache.read-cache-enabled` – enable/disable query cache (default: `true`) +- Build-time ClassGraph scan: automatic GraalVM reflection registration for all + `@Entity` and `@Embedded` annotated classes (no manual `reflect-config.json` required) +- Graceful shutdown via `@Observes ShutdownEvent` – `Morphium.close()` called automatically +- Java 25 compatible: no `sun.*` imports, no `Unsafe` access, no `--add-opens` for internal APIs +- `InMemDriver` support for `@QuarkusTest` without a running MongoDB instance +- Quarkus 3.32.1 support +- Dev Services: automatic MongoDB container start in dev and test mode via Testcontainers + (`quarkus.morphium.devservices.*` config group; disabled when `quarkus.morphium.hosts` is set explicitly) diff --git a/quarkus-morphium/README.md b/quarkus-morphium/README.md new file mode 100644 index 000000000..93c52c92c --- /dev/null +++ b/quarkus-morphium/README.md @@ -0,0 +1,453 @@ +# Quarkus Morphium Extension + +A [Quarkus](https://quarkus.io) CDI extension for [Morphium](https://github.com/sboesebeck/morphium), +an actively maintained MongoDB ORM for Java — with full **Jakarta Data 1.0** support. + +> **Module status:** this extension is now an optional module of the +> [Morphium](https://github.com/sboesebeck/morphium) reactor (`quarkus-morphium/`), +> built and released in lockstep with the Morphium core. The core does not depend on +> this module — building Morphium without extensions (`-DskipExtensions`) is unaffected. + +### What's new in v1.2.0 + + +- **`MorphiumId` serializes as a hex string by default** — entities with `@Id MorphiumId id` + now emit `"id":""` over REST (Jackson **and** JSON-B), and `MorphiumId` path/query/body + parameters deserialize from the hex string. No serializer to write yourself. + - **BREAKING (positive):** replaces the old internal struct + `{"pid":..,"counter":..,"machineId":..,"bytes":"..","time":..}`, which was unusable as a + row id on the consumer side (frontend grids got `"[object Object]"`, lost row identity, and + crashed the renderer). Clients that parsed the old shape must update — none should. + +### What's new in v1.1.1 + +- **Morphium 6.2.1** — now built against the upstream release (no longer requires fork SNAPSHOT) +- **JDQL `NOT BETWEEN`** — `WHERE NOT price BETWEEN :min AND :max` +- **JDQL `NOT (...)` groups** — `WHERE NOT (status = 'OPEN' OR status = 'PENDING')` with De Morgan transformation +- **JDQL error messages** — parse errors now include position and caret pointer +- **Optional health checks** — `quarkus-smallrye-health` is no longer forced on downstream apps +- **Dev UI fix** — external MongoDB connections now show actual host/database instead of `n/a` +- **deleteBy* fix** — uses `query.delete()` instead of loading all entities into memory +- **Write buffer in transactions** — `@MorphiumTransactional` disables write buffering automatically +- **Regex patterns extracted** — JDQL parser patterns compiled once as static fields + +

    +What was new in v1.1.0 + +- **JDQL Aggregation:** `COUNT`, `SUM`, `AVG`, `MIN`, `MAX` with `GROUP BY` (single + multi-field), `HAVING` (AND/OR), `COUNT(field)` NULL filtering +- **Stream:** `Stream` return type with cursor-backed lazy loading +- **Async:** `CompletionStage` for non-blocking repository methods +- **Keyset pagination:** `CursoredPage` for efficient large-collection paging +- **JDQL SELECT projection:** `SELECT field1, field2 WHERE ...` +- **JDQL NOT + string literals:** `WHERE NOT status = 'CANCELLED'` +- **GROUP BY pagination:** `Page` return type for aggregated results +- **Jakarta Data exceptions:** `EmptyResultException`, `NonUniqueResultException` +- **New query operators:** Contains, Empty, Size, Matches, IgnoreCase, deleteBy* +- **223 integration tests** — all green +
    + +**[Documentation](docs/modules/ROOT/pages/index.adoc)** | **[Showcase Source](https://github.com/Bardioc1977/quarkus-morphium-showcase)** + +--- + +## Jakarta Data 1.0 — Declarative Repositories for MongoDB + +Define a `@Repository` interface, inject it, done. The extension generates the implementation +at **Quarkus build time** via Gizmo bytecode generation — no runtime reflection, no proxies, +GraalVM native-image compatible. + +```java +@Repository +public interface ProductRepository extends CrudRepository { + + List findByCategory(String category); + + @OrderBy("price") + List findByPriceBetween(double min, double max); + + long countByCategory(String category); + + boolean existsByName(String name); + + Page findByCategory(String category, PageRequest page); + + @Find + List search(@By("category") String cat, + @By("price") double minPrice, + Sort sort); + + @Query("WHERE category = :cat AND price > :minPrice ORDER BY price") + List findExpensive(@Param("cat") String category, + @Param("minPrice") double minPrice); + + // GROUP BY with aggregates and HAVING + @Query("SELECT category, COUNT(this), SUM(price) GROUP BY category HAVING COUNT(this) > :min") + List categoriesAboveMin(@Param("min") long minCount); + + // Async query + CompletionStage> findByCategoryAsync(String category); + + // Stream for large result sets + Stream findByPriceGreaterThan(double minPrice); +} +``` + +```java +@ApplicationScoped +public class ProductService { + + @Inject ProductRepository products; + + public Page browse(int page, int size) { + return products.findByCategory("electronics", + PageRequest.ofPage(page, size, true)); + } +} +``` + +### What's supported + +| Feature | Details | +|---------|---------| +| **CRUD** | `CrudRepository`, `BasicRepository`, `DataRepository`, `MorphiumRepository` — save, insert, update, delete, findById, findAll, existsById | +| **Query derivation** | `findBy`, `countBy`, `existsBy`, `deleteBy` with operators: Equals, Not, GreaterThan, LessThan, Between, In, NotIn, Like, StartsWith, EndsWith, Null, NotNull, True, False — combined with And/Or | +| **@Find + @By** | Explicit field binding via parameter annotations; each `@By`-bound parameter is applied as an equality condition | +| **@Query (JDQL)** | Jakarta Data Query Language with WHERE, ORDER BY, named parameters (`:param`), comparison operators, BETWEEN, IN, LIKE, IS NULL, NOT, string literals, GROUP BY (single + multi-field), HAVING (AND/OR), aggregate functions (COUNT/SUM/AVG/MIN/MAX) | +| **@OrderBy** | Static sort annotation on query methods | +| **Pagination** | `Page`, `PageRequest` with total counts, `Limit`, `CursoredPage` (keyset pagination), `Page` for GROUP BY results | +| **Sorting** | `Sort`, `Order` as method parameters | +| **Stream** | `Stream` return type with cursor-backed lazy loading for memory-efficient large result sets | +| **Async** | `CompletionStage` return type for non-blocking repository methods (query derivation, `@Find`, `@Query`) | +| **@StaticMetamodel** | Auto-generated `Entity_` classes with `Attribute`, `SortableAttribute`, `TextAttribute` fields — type-safe field references | +| **Build-time validation** | Entity fields, ID types, method signatures validated during `mvn compile` — fail fast, not at runtime | + +> **Note:** `@By` currently only supports equality conditions. Jakarta Data's `@Is(Operator)` +> annotation for non-equality `@By` conditions (e.g. `@By("price") @Is(GreaterThanEqual)`) +> requires Jakarta Data 1.1, which is not yet finalized (latest available artifact as of this +> writing is the `1.1.0-M3` milestone) — this module targets the stable `jakarta.data-api:1.0.0`. +> Support for `@Is` is a natural candidate once Jakarta Data 1.1 ships as a final release; for +> non-equality conditions today, use query derivation (`findByPriceGreaterThan(...)`) or `@Query` +> (JDQL) instead. + +All Morphium ORM features work transparently through generated repositories: `@Version` +(optimistic locking), `@CreationTime`/`@LastChange`, lifecycle callbacks (`@PreStore`, +`@PostLoad`), `@Cache`, `@WriteBuffer`, and `@Reference` (lazy/eager) — because the +generated implementation delegates to `morphium.store()`, `morphium.findById()` etc. + +### MorphiumRepository — The Escape Hatch + +`MorphiumRepository` extends `CrudRepository` with Morphium-specific operations that +have no equivalent in Jakarta Data 1.0: + +```java +@Repository +public interface ProductRepository extends MorphiumRepository { + + List findByCategory(String category); // Jakarta Data query derivation +} +``` + +```java +// Distinct values for a field +List categories = products.distinct("category"); + +// Direct access to Morphium API for aggregation, atomic updates, etc. +products.morphium().inc(product, "stock", 5); + +// Create a typed Morphium Query for complex conditions +Query q = products.query(); +q.f("price").gt(100).f("category").eq("electronics"); +``` + +All standard Jakarta Data features work exactly the same as with `CrudRepository`. +The imperative Morphium API (`@Inject Morphium`) also remains fully available for +aggregation pipelines, bulk updates, and anything beyond standard CRUD. + +--- + +## All Features + +### CDI & Lifecycle +- **Zero-boilerplate CDI integration** — inject `Morphium` or any `@Repository` interface directly via `@Inject` +- **Declarative transactions** — `@MorphiumTransactional` with automatic commit/rollback and CDI lifecycle events (`BEFORE_COMMIT`, `AFTER_COMMIT`, `AFTER_ROLLBACK`) +- **Graceful shutdown** — `Morphium.close()` called automatically on application stop + +### Developer Experience +- **`MorphiumId` JSON out of the box** — `@Id MorphiumId id` serializes to a flat hex string (`"id":""`) and parses back from one, for both Jackson and JSON-B, with no user-written serializer +- **Type-safe configuration** — all settings under `quarkus.morphium.*` in `application.properties` +- **Dev Services** — automatic MongoDB container in dev/test mode via Testcontainers, with optional single-node replica set for transactions +- **Dev UI card** — MongoDB connection info in the Quarkus Dev UI at `/q/dev-ui/` +- **Test-friendly** — `quarkus.morphium.driver-name=InMemDriver` for fast, in-process tests without Docker +- **Blocking call detection** — warns when Morphium writes happen on the Vert.x event loop + +### Production +- **Health checks** — MicroProfile liveness, readiness, and startup probes with connection pool metadata +- **SSL/TLS & X.509** — encrypted connections and client-certificate authentication via `quarkus.morphium.ssl.*` +- **GraalVM native ready** — all `@Entity` and `@Embedded` classes registered for reflection at build time +- **CosmosDB compatibility** — `@MorphiumTransactional` gracefully degrades on Azure CosmosDB (auto-detected); supports all Azure sovereign clouds + +### Morphium ORM +- **@Reference cascade** — `cascadeDelete` and `orphanRemoval` with automatic cycle detection for bidirectional references +- **Built-in caching** — `@Cache` and `@WriteBuffer` annotations for read cache and async write batching +- **Lifecycle hooks** — `@PreStore`, `@PostStore`, `@PostLoad` etc. on `@Entity` classes +- **Optimistic locking** — `@Version` for concurrent modification detection +- **Schema evolution** — `@Aliases` for legacy field name compatibility + +--- + +## Prerequisites + + + +| Dependency | Minimum version | +|---|---| +| Java | 21 | +| Quarkus | 3.32.3 | +| Morphium | 6.3.0-SNAPSHOT (built in lockstep as part of the [sboesebeck/morphium](https://github.com/sboesebeck/morphium) reactor) | + +## Installation + +This extension is a module of the Morphium reactor. Add it to your application's +`pom.xml`: + +```xml + + de.caluga + quarkus-morphium + 6.3.0-SNAPSHOT + +``` + +### Migrating from the standalone `io.quarkiverse.morphium` extension + +If you previously depended on the standalone Quarkiverse extension, update your +coordinates: + +| | Before | After | +|---|---|---| +| groupId | `io.quarkiverse.morphium` | `de.caluga` | +| artifactId | `quarkus-morphium` | `quarkus-morphium` (unchanged) | +| version | `1.2.0` (or earlier) | `6.3.x` (tracks the Morphium core release) | + +No package renames, no API changes — only the Maven coordinates move. All +`quarkus.morphium.*` configuration properties are unchanged. + +## Quick Start + +### 1. Configure + +```properties +# Required +quarkus.morphium.database=my-database + +# MongoDB hosts (default: localhost:27017) +quarkus.morphium.hosts=mongo1:27017,mongo2:27017 + +# Or use Dev Services — no config needed, MongoDB starts automatically +``` + +### 2. Define an entity + +```java +@Entity(collectionName = "products") +@Data @NoArgsConstructor +public class Product { + @Id private MorphiumId id; + private String name; + private double price; + private String category; + @Version private long version; +} +``` + +### 3. Create a repository + +```java +@Repository +public interface ProductRepository extends CrudRepository { + + List findByCategory(String category); + + @OrderBy("price") + List findByPriceGreaterThan(double minPrice); +} +``` + +### 4. Use it + +```java +@ApplicationScoped +public class ProductService { + + @Inject ProductRepository products; + + public Product create(String name, double price, String category) { + var product = new Product(); + product.setName(name); + product.setPrice(price); + product.setCategory(category); + return products.insert(product); + } + + public List findExpensive(double minPrice) { + return products.findByPriceGreaterThan(minPrice); + } +} +``` + +### Imperative API (always available) + +For complex queries, aggregations, or atomic operations, inject `Morphium` directly: + +```java +@Inject Morphium morphium; + +public List> salesByCategory() { + return morphium.createAggregator(Product.class, Map.class) + .group("$category").sum("total", "$price").end() + .sort("-total") + .aggregateMap(); +} +``` + +## Configuration Reference + +| Property | Default | Description | +|---|---|---| +| `quarkus.morphium.database` | *(required)* | MongoDB database name | +| `quarkus.morphium.hosts` | `localhost:27017` | Comma-separated `host:port` list | +| `quarkus.morphium.username` | -- | MongoDB username | +| `quarkus.morphium.password` | -- | MongoDB password | +| `quarkus.morphium.auth-database` | `admin` | Authentication database | +| `quarkus.morphium.atlas-url` | -- | MongoDB Atlas SRV URL (overrides `hosts`) | +| `quarkus.morphium.read-preference` | `primary` | Read preference | +| `quarkus.morphium.index-check` | `create-on-startup` | Index creation strategy (`create-on-startup`, `warn-on-startup`, `create-on-write-new-col`, `no-check`) | +| `quarkus.morphium.max-connections` | `250` | Connection pool size | +| `quarkus.morphium.max-wait-time` | `2000` | Max wait (ms) for a pooled connection / driver-level timeout | +| `quarkus.morphium.default-query-timeout-ms` | `0` | Default server-side query time limit (ms); `0` disables it | +| `quarkus.morphium.replica-set-name` | -- | Replica set name; required for `@MorphiumTransactional` and change streams | +| `quarkus.morphium.connect-retries` | `5` | Connection attempts before giving up | +| `quarkus.morphium.driver-name` | `PooledDriver` | `PooledDriver` (production) or `InMemDriver` (tests) | +| `quarkus.morphium.cache.read-cache-enabled` | `true` | Enable query result cache | +| `quarkus.morphium.cache.global-valid-time` | `60000` | Cache TTL in milliseconds | +| `quarkus.morphium.local-date-time.use-bson-date` | `true` | Store `LocalDateTime` as BSON `ISODate` | +| `quarkus.morphium.ssl.enabled` | `false` | Enable TLS | +| `quarkus.morphium.ssl.auth-mechanism` | -- | `MONGODB-X509` for client-cert auth | +| `quarkus.morphium.ssl.keystore-path` | -- | Keystore path (JKS/PKCS12) | +| `quarkus.morphium.ssl.keystore-password` | -- | Keystore password | +| `quarkus.morphium.ssl.truststore-path` | -- | Truststore path | +| `quarkus.morphium.ssl.truststore-password` | -- | Truststore password | +| `quarkus.morphium.ssl.invalid-hostname-allowed` | `false` | Allow invalid hostnames (dev only) | +| `quarkus.morphium.ssl.x509-username` | -- | X.509 subject DN override | +| `quarkus.morphium.ssl.tls-configuration-name` | -- | Name of a Quarkus TLS configuration (`quarkus.tls..*`) to use instead of explicit keystore/truststore paths; `` selects the unnamed default. Falls back to the default Quarkus TLS configuration automatically when no explicit keystore/truststore is set and one is available. | +| `quarkus.morphium.devservices.enabled` | `true` | Enable automatic MongoDB container | +| `quarkus.morphium.devservices.image-name` | `mongo:8` | Docker image for Dev Services | +| `quarkus.morphium.devservices.database-name` | `morphium-dev` | Database name in Dev Services | +| `quarkus.morphium.devservices.replica-set` | `true` | Start as replica set (enables transactions) | +| `quarkus.morphium.health.enabled` | `true` | Enable health checks | +| `quarkus.morphium.migration.migrate-at-start` | `false` | Run pending migrations automatically at startup | +| `quarkus.morphium.migration.change-log-collection` | `morphiumChangeLog` | Collection tracking executed migrations | +| `quarkus.morphium.migration.lock-collection` | `morphiumMigrationLock` | Collection used for the distributed migration lock | +| `quarkus.morphium.migration.lock-ttl-seconds` | `60` | Migration lock TTL in seconds (renewed between migrations) | +| `quarkus.morphium.migration.lock-wait-seconds` | `0` | Seconds to wait for a held migration lock before failing (`0` = fail immediately) | + +For detailed descriptions, see the +[Configuration Reference](docs/modules/ROOT/pages/configuration.adoc). + +## Transactions + +```java +@ApplicationScoped +public class OrderService { + + @Inject Morphium morphium; + + @MorphiumTransactional + public void placeOrder(Order order, Payment payment) { + morphium.store(order); + morphium.store(payment); + // auto-commit on success, auto-rollback on exception + } +} +``` + +React to transaction events via CDI: + +```java +void afterCommit(@Observes @MorphiumTxPhase(AFTER_COMMIT) MorphiumTransactionEvent e) { + // send confirmation, publish domain event, ... +} +``` + +## Testing + +```properties +# src/test/resources/application.properties +%test.quarkus.morphium.driver-name=InMemDriver +%test.quarkus.morphium.database=test-db +``` + +```java +@QuarkusTest +class ProductRepositoryTest { + + @Inject ProductRepository repository; + + @Test + void shouldFindByCategory() { + var p = new Product(); + p.setName("Widget"); + p.setCategory("tools"); + p.setPrice(9.99); + repository.save(p); + + var results = repository.findByCategory("tools"); + assertThat(results).hasSize(1); + assertThat(results.get(0).getName()).isEqualTo("Widget"); + } +} +``` + +## Known Limitation: `sun.misc.Unsafe` + +The Morphium ORM uses `sun.misc.Unsafe.allocateInstance()` to instantiate entity classes that +**do not have a no-arg constructor**. This is the de facto standard used by Spring, Jackson, +Gson, Kryo, Hibernate/Objenesis and others. + +**To avoid it:** add a no-arg constructor (can be `private` or package-private) to your +`@Entity` classes. When present, Morphium uses it directly and `Unsafe` is never called. + +`Unsafe.allocateInstance()` is **not** covered by [JEP 471](https://openjdk.org/jeps/471) (JDK 23). +Once a public replacement API exists, Morphium will migrate to it. + +## Building from Source + +This module is built as part of the Morphium reactor: + +```bash +cd morphium +mvn -pl quarkus-morphium -am verify +``` + +`-am` also builds `morphium-core` and `morphium-jakarta-data`, this extension's direct +dependencies, in the same reactor run. + +## Related Projects + +- [quarkus-morphium-showcase](https://github.com/Bardioc1977/quarkus-morphium-showcase) — interactive demo source code +- [Morphium](https://github.com/sboesebeck/morphium) — the underlying MongoDB ORM +- [Quarkus](https://quarkus.io) — supersonic, subatomic Java framework +- [Jakarta Data 1.0](https://jakarta.ee/specifications/data/1.0/) — the specification + +## Contributing + +Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). + +## License + +[Apache License 2.0](LICENSE) diff --git a/quarkus-morphium/deployment/pom.xml b/quarkus-morphium/deployment/pom.xml new file mode 100644 index 000000000..36c4f060e --- /dev/null +++ b/quarkus-morphium/deployment/pom.xml @@ -0,0 +1,131 @@ + + + 4.0.0 + + + de.caluga + quarkus-morphium-parent + 6.3.2-SNAPSHOT + + + quarkus-morphium-deployment + Quarkus Morphium Extension – Deployment + + + + + ${project.groupId} + quarkus-morphium + ${project.version} + + + + io.quarkus + quarkus-core-deployment + + + io.quarkus + quarkus-arc-deployment + + + + io.quarkus + quarkus-smallrye-health-spi + + + + + + io.quarkus + quarkus-jackson-deployment + true + + + io.quarkus + quarkus-jsonb-deployment + true + + + + + io.quarkus + quarkus-tls-registry-deployment + + + + + io.quarkus + quarkus-devservices-deployment + + + + + io.quarkus + quarkus-devui-deployment-spi + + + + + org.testcontainers + testcontainers + + + + org.testcontainers + testcontainers-mongodb + + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + + org.apache.maven.plugins + maven-source-plugin + + + org.apache.maven.plugins + maven-javadoc-plugin + + + + diff --git a/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MongoDBStartable.java b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MongoDBStartable.java new file mode 100644 index 000000000..d70811206 --- /dev/null +++ b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MongoDBStartable.java @@ -0,0 +1,102 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.deployment; + +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.mongodb.MongoDBContainer; +import org.testcontainers.utility.DockerImageName; +import org.testcontainers.utility.ImageNameSubstitutor; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Wrapper around a Testcontainers MongoDB container, managed by + * {@link MorphiumDevServicesProcessor} via static volatile fields. + */ +class MongoDBStartable { + + private static final int MONGO_PORT = 27017; + private static final Pattern REPLICA_SET_PATTERN = Pattern.compile("[?&]replicaSet=([^&]+)"); + + private final String imageName; + private final boolean replicaSet; + private GenericContainer container; + + MongoDBStartable(String imageName, boolean replicaSet) { + this.imageName = imageName; + this.replicaSet = replicaSet; + } + + @SuppressWarnings("resource") + void start() { + if (container != null) { + return; + } + DockerImageName base = DockerImageName.parse(imageName); + DockerImageName substituted = ImageNameSubstitutor.instance().apply(base) + .asCompatibleSubstituteFor("mongo"); + + if (replicaSet) { + container = new MongoDBContainer(substituted).withReplicaSet(); + } else { + container = new GenericContainer<>(substituted) + .withExposedPorts(MONGO_PORT) + .waitingFor(Wait.forLogMessage(".*Waiting for connections.*\n", 1)); + } + container.start(); + } + + void close() { + if (container != null && container.isRunning()) { + container.stop(); + } + } + + String getHost() { + ensureStarted(); + return container.getHost(); + } + + String getContainerId() { + return container != null ? container.getContainerId() : null; + } + + int getMappedPort() { + ensureStarted(); + return container.getMappedPort(MONGO_PORT); + } + + boolean isReplicaSet() { + return replicaSet; + } + + String getReplicaSetName() { + if (container instanceof MongoDBContainer mongoContainer) { + String connStr = mongoContainer.getConnectionString(); + Matcher m = REPLICA_SET_PATTERN.matcher(connStr); + return m.find() ? m.group(1) : "docker-rs"; + } + return null; + } + + private void ensureStarted() { + if (container == null) { + throw new IllegalStateException("MongoDBStartable has not been started yet"); + } + } +} diff --git a/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumDataProcessor.java b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumDataProcessor.java new file mode 100644 index 000000000..91b82dc67 --- /dev/null +++ b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumDataProcessor.java @@ -0,0 +1,2064 @@ +package de.caluga.morphium.quarkus.deployment; + +import de.caluga.morphium.data.AbstractMorphiumRepository; +import de.caluga.morphium.data.FindMethodBridge; +import de.caluga.morphium.data.JdqlMethodBridge; +import de.caluga.morphium.data.MethodNameParser; +import de.caluga.morphium.data.QueryDescriptor; +import de.caluga.morphium.data.QueryMethodBridge; +import de.caluga.morphium.data.RepositoryMetadata; +import de.caluga.morphium.quarkus.data.QuarkusMorphiumRepository; +import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.arc.deployment.GeneratedBeanBuildItem; +import io.quarkus.arc.deployment.GeneratedBeanGizmoAdaptor; +import io.quarkus.deployment.GeneratedClassGizmoAdaptor; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.builditem.CombinedIndexBuildItem; +import io.quarkus.deployment.builditem.GeneratedClassBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; +import io.quarkus.gizmo.ClassCreator; +import io.quarkus.gizmo.ClassOutput; +import io.quarkus.gizmo.FieldCreator; +import io.quarkus.gizmo.FieldDescriptor; +import io.quarkus.gizmo.MethodCreator; +import io.quarkus.gizmo.MethodDescriptor; +import io.quarkus.gizmo.ResultHandle; +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTarget; +import org.jboss.jandex.AnnotationValue; +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.DotName; +import org.jboss.jandex.FieldInfo; +import org.jboss.jandex.IndexView; +import org.jboss.jandex.MethodInfo; +import org.jboss.jandex.MethodParameterInfo; +import org.jboss.jandex.ParameterizedType; +import org.jboss.jandex.PrimitiveType; +import org.jboss.jandex.Type; +import org.jboss.jandex.TypeVariable; +import org.jboss.logging.Logger; + +import java.lang.reflect.Modifier; +import java.util.*; +import java.util.concurrent.CompletionStage; +import java.util.stream.Stream; + +/** + * Build-time processor for Jakarta Data {@code @Repository} interfaces. + *

    + * Discovers repository interfaces via Jandex, validates them, and generates + * implementation classes via Gizmo that extend {@link AbstractMorphiumRepository} + * and delegate to its {@code doXxx()} methods. + */ +public class MorphiumDataProcessor { + + private static final Logger log = Logger.getLogger(MorphiumDataProcessor.class); + + private static final DotName REPOSITORY_ANNOTATION = DotName.createSimple( + "jakarta.data.repository.Repository"); + private static final DotName DATA_REPOSITORY = DotName.createSimple( + "jakarta.data.repository.DataRepository"); + private static final DotName BASIC_REPOSITORY = DotName.createSimple( + "jakarta.data.repository.BasicRepository"); + private static final DotName CRUD_REPOSITORY = DotName.createSimple( + "jakarta.data.repository.CrudRepository"); + private static final DotName MORPHIUM_REPOSITORY = DotName.createSimple( + "de.caluga.morphium.data.MorphiumRepository"); + private static final DotName ENTITY_ANNOTATION = DotName.createSimple( + "de.caluga.morphium.annotations.Entity"); + private static final DotName ID_ANNOTATION = DotName.createSimple( + "de.caluga.morphium.annotations.Id"); + + // Jakarta Data lifecycle/query annotations + private static final DotName FIND_ANNOTATION = DotName.createSimple( + "jakarta.data.repository.Find"); + private static final DotName BY_ANNOTATION = DotName.createSimple( + "jakarta.data.repository.By"); + private static final DotName ORDER_BY_ANNOTATION = DotName.createSimple( + "jakarta.data.repository.OrderBy"); + private static final DotName ORDER_BY_LIST_ANNOTATION = DotName.createSimple( + "jakarta.data.repository.OrderBy$List"); + private static final DotName DELETE_ANNOTATION = DotName.createSimple( + "jakarta.data.repository.Delete"); + private static final DotName INSERT_ANNOTATION = DotName.createSimple( + "jakarta.data.repository.Insert"); + private static final DotName SAVE_ANNOTATION = DotName.createSimple( + "jakarta.data.repository.Save"); + private static final DotName UPDATE_ANNOTATION = DotName.createSimple( + "jakarta.data.repository.Update"); + private static final DotName QUERY_ANNOTATION = DotName.createSimple( + "jakarta.data.repository.Query"); + private static final DotName PARAM_ANNOTATION = DotName.createSimple( + "jakarta.data.repository.Param"); + + // Special parameter types + private static final DotName SORT_TYPE = DotName.createSimple("jakarta.data.Sort"); + private static final DotName ORDER_TYPE = DotName.createSimple("jakarta.data.Order"); + private static final DotName PAGE_REQUEST_TYPE = DotName.createSimple("jakarta.data.page.PageRequest"); + private static final DotName LIMIT_TYPE = DotName.createSimple("jakarta.data.Limit"); + private static final DotName PAGE_TYPE = DotName.createSimple("jakarta.data.page.Page"); + private static final DotName CURSORED_PAGE_TYPE = DotName.createSimple("jakarta.data.page.CursoredPage"); + private static final DotName COMPLETION_STAGE_TYPE = DotName.createSimple("java.util.concurrent.CompletionStage"); + + // Metamodel types + private static final String STATIC_METAMODEL_ANN = "jakarta.data.metamodel.StaticMetamodel"; + private static final String ATTRIBUTE_CLASS = "jakarta.data.metamodel.Attribute"; + private static final String SORTABLE_ATTRIBUTE_CLASS = "jakarta.data.metamodel.SortableAttribute"; + private static final String TEXT_ATTRIBUTE_CLASS = "jakarta.data.metamodel.TextAttribute"; + private static final String ATTRIBUTE_RECORD_CLASS = "jakarta.data.metamodel.impl.AttributeRecord"; + private static final String SORTABLE_ATTRIBUTE_RECORD_CLASS = "jakarta.data.metamodel.impl.SortableAttributeRecord"; + private static final String TEXT_ATTRIBUTE_RECORD_CLASS = "jakarta.data.metamodel.impl.TextAttributeRecord"; + + // Morphium @Transient and @Property annotations + private static final DotName TRANSIENT_ANNOTATION = DotName.createSimple( + "de.caluga.morphium.annotations.Transient"); + private static final DotName PROPERTY_ANNOTATION = DotName.createSimple( + "de.caluga.morphium.annotations.Property"); + + // Common types for metamodel classification + private static final Set SORTABLE_TYPES = Set.of( + "byte", "short", "int", "long", "float", "double", "char", "boolean", + "java.lang.Byte", "java.lang.Short", "java.lang.Integer", "java.lang.Long", + "java.lang.Float", "java.lang.Double", "java.lang.Character", "java.lang.Boolean", + "java.math.BigDecimal", "java.math.BigInteger", + "java.time.LocalDate", "java.time.LocalDateTime", "java.time.LocalTime", + "java.time.Instant", "java.time.ZonedDateTime", "java.time.OffsetDateTime", + "java.util.Date"); + + // Standard CRUD/Basic method names that are handled by delegation + private static final Set CRUD_METHODS = Set.of( + "findById", "findAll", "save", "saveAll", "delete", "deleteById", "deleteAll", + "insert", "insertAll", "update", "updateAll", + "distinct", "morphium", "query"); + + // ----------------------------------------------------------------- + // Step 1: Discover @Repository interfaces + // ----------------------------------------------------------------- + + @BuildStep + void discoverRepositories(CombinedIndexBuildItem combinedIndex, + BuildProducer repositoryProducer) { + IndexView index = combinedIndex.getIndex(); + + for (AnnotationInstance ann : index.getAnnotations(REPOSITORY_ANNOTATION)) { + if (ann.target().kind() != AnnotationTarget.Kind.CLASS) continue; + + ClassInfo repoClass = ann.target().asClass(); + if (!repoClass.isInterface()) { + log.warnf("@Repository on non-interface %s — skipping", repoClass.name()); + continue; + } + + // Find the DataRepository/BasicRepository/CrudRepository superinterface and extract T, K + TypeParameters tp = resolveEntityAndIdTypes(repoClass, index); + if (tp == null) { + log.warnf("@Repository %s does not extend DataRepository/BasicRepository/CrudRepository — skipping", + repoClass.name()); + continue; + } + + // Find @Id field on entity class + ClassInfo entityClassInfo = index.getClassByName(tp.entityType); + if (entityClassInfo == null) { + throw new IllegalStateException( + "@Repository " + repoClass.name() + " references entity " + tp.entityType + + " which is not in the Jandex index. Ensure it is annotated with @Entity."); + } + + String idFieldName = findIdField(entityClassInfo, index); + if (idFieldName == null) { + throw new IllegalStateException( + "Entity " + tp.entityType + " referenced by @Repository " + repoClass.name() + + " has no @Id field."); + } + + log.infof("Discovered @Repository %s → entity=%s, id=%s, idField=%s", + repoClass.name(), tp.entityType, tp.idType, idFieldName); + + repositoryProducer.produce(new RepositoryBuildItem( + repoClass.name().toString(), + tp.entityType.toString(), + tp.idType.toString(), + idFieldName)); + } + } + + // ----------------------------------------------------------------- + // Step 2: Generate repository implementations via Gizmo + // ----------------------------------------------------------------- + + @BuildStep + void generateRepositoryImpls(List repositories, + CombinedIndexBuildItem combinedIndex, + BuildProducer generatedBeans, + BuildProducer reflectiveClasses, + BuildProducer additionalBeans) { + if (repositories.isEmpty()) return; + + // Register QuarkusMorphiumRepository as a bean + additionalBeans.produce(AdditionalBeanBuildItem.builder() + .addBeanClass(QuarkusMorphiumRepository.class) + .setUnremovable() + .build()); + + IndexView index = combinedIndex.getIndex(); + ClassOutput classOutput = new GeneratedBeanGizmoAdaptor(generatedBeans); + + for (RepositoryBuildItem repo : repositories) { + generateImpl(repo, index, classOutput, reflectiveClasses); + } + } + + // ----------------------------------------------------------------- + // Step 3: Generate @StaticMetamodel classes + // ----------------------------------------------------------------- + + @BuildStep + void generateStaticMetamodels(List repositories, + CombinedIndexBuildItem combinedIndex, + BuildProducer generatedClasses, + BuildProducer reflectiveClasses) { + if (repositories.isEmpty()) return; + + IndexView index = combinedIndex.getIndex(); + ClassOutput classOutput = new GeneratedClassGizmoAdaptor(generatedClasses, true); + + // Collect unique entity classes + Set processedEntities = new LinkedHashSet<>(); + for (RepositoryBuildItem repo : repositories) { + String entityClassName = repo.getEntityClassName(); + if (processedEntities.add(entityClassName)) { + ClassInfo entityClass = index.getClassByName(DotName.createSimple(entityClassName)); + if (entityClass != null) { + generateMetamodel(entityClassName, entityClass, index, classOutput, reflectiveClasses); + } + } + } + } + + private void generateMetamodel(String entityClassName, + ClassInfo entityClass, + IndexView index, + ClassOutput classOutput, + BuildProducer reflectiveClasses) { + String metamodelClassName = entityClassName + "_"; + + try (ClassCreator cc = ClassCreator.builder() + .classOutput(classOutput) + .className(metamodelClassName) + .superClass(Object.class) + .build()) { + + // Add @StaticMetamodel(EntityClass.class) annotation + cc.addAnnotation(STATIC_METAMODEL_ANN) + .addValue("value", AnnotationValue.createClassValue("value", + Type.create(DotName.createSimple(entityClassName), Type.Kind.CLASS))); + + // Collect persistent fields from entity hierarchy + List fields = collectMetamodelFields(entityClass, index); + + // Generate String constants (public static final String FIELD_NAME = "javaName") + for (MetamodelField mf : fields) { + FieldCreator fc = cc.getFieldCreator(mf.constantName, String.class); + fc.setModifiers(Modifier.PUBLIC + | Modifier.STATIC + | Modifier.FINAL); + } + + // Generate Attribute fields (public static final XxxAttribute field) + for (MetamodelField mf : fields) { + String attributeType = mf.attributeInterfaceType(); + FieldCreator fc = cc.getFieldCreator(mf.javaName, attributeType); + fc.setModifiers(Modifier.PUBLIC + | Modifier.STATIC + | Modifier.FINAL); + } + + // Generate static initializer + try (MethodCreator clinit = cc.getMethodCreator("", void.class)) { + clinit.setModifiers(Modifier.STATIC); + + for (MetamodelField mf : fields) { + // Assign String constant: FIELD_NAME = "javaName" + ResultHandle nameValue = clinit.load(mf.javaName); + clinit.writeStaticField( + FieldDescriptor.of(metamodelClassName, mf.constantName, String.class), + nameValue); + + // Create attribute record: new XxxAttributeRecord<>("javaName") + String recordClass = mf.attributeRecordType(); + ResultHandle attrInstance = clinit.newInstance( + MethodDescriptor.ofConstructor(recordClass, String.class), + nameValue); + + // Assign: field = new XxxAttributeRecord<>("javaName") + clinit.writeStaticField( + FieldDescriptor.of(metamodelClassName, mf.javaName, mf.attributeInterfaceType()), + attrInstance); + } + + clinit.returnVoid(); + } + + log.infof("Generated @StaticMetamodel: %s", metamodelClassName); + } + + reflectiveClasses.produce(ReflectiveClassBuildItem.builder(metamodelClassName) + .constructors(true).methods(true).fields(true).build()); + } + + private record MetamodelField(String javaName, String constantName, FieldCategory category) { + + String attributeInterfaceType() { + return switch (category) { + case TEXT -> TEXT_ATTRIBUTE_CLASS; + case SORTABLE -> SORTABLE_ATTRIBUTE_CLASS; + case BASIC -> ATTRIBUTE_CLASS; + }; + } + + String attributeRecordType() { + return switch (category) { + case TEXT -> TEXT_ATTRIBUTE_RECORD_CLASS; + case SORTABLE -> SORTABLE_ATTRIBUTE_RECORD_CLASS; + case BASIC -> ATTRIBUTE_RECORD_CLASS; + }; + } + } + + private enum FieldCategory { TEXT, SORTABLE, BASIC } + + private List collectMetamodelFields(ClassInfo entityClass, IndexView index) { + List result = new ArrayList<>(); + ClassInfo current = entityClass; + + while (current != null) { + for (FieldInfo field : current.fields()) { + // Skip static, transient, and @Transient fields + if (Modifier.isStatic(field.flags())) continue; + if (Modifier.isTransient(field.flags())) continue; + if (field.hasAnnotation(TRANSIENT_ANNOTATION)) continue; + + String javaName = field.name(); + String constantName = toUpperSnakeCase(javaName); + FieldCategory category = classifyField(field, index); + + result.add(new MetamodelField(javaName, constantName, category)); + } + DotName superName = current.superName(); + if (superName == null || superName.toString().equals("java.lang.Object")) break; + current = index.getClassByName(superName); + } + + return result; + } + + private FieldCategory classifyField(FieldInfo field, IndexView index) { + String typeName = field.type().name().toString(); + + if ("java.lang.String".equals(typeName) || "char".equals(typeName) + || "java.lang.Character".equals(typeName)) { + return FieldCategory.TEXT; + } + + if (SORTABLE_TYPES.contains(typeName)) { + return FieldCategory.SORTABLE; + } + + // Check if it's an enum (enums are sortable) + ClassInfo typeInfo = index.getClassByName(field.type().name()); + if (typeInfo != null && typeInfo.isEnum()) { + return FieldCategory.SORTABLE; + } + + return FieldCategory.BASIC; + } + + private static String toUpperSnakeCase(String camelCase) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < camelCase.length(); i++) { + char c = camelCase.charAt(i); + if (Character.isUpperCase(c) && i > 0) { + sb.append('_'); + } + sb.append(Character.toUpperCase(c)); + } + return sb.toString(); + } + + // ----------------------------------------------------------------- + // Gizmo code generation + // ----------------------------------------------------------------- + + private void generateImpl(RepositoryBuildItem repo, + IndexView index, + ClassOutput classOutput, + BuildProducer reflectiveClasses) { + + String implClassName = repo.getInterfaceName() + "_MorphiumImpl"; + String entityClassName = repo.getEntityClassName(); + String idClassName = repo.getIdClassName(); + String idFieldName = repo.getIdFieldName(); + + // Determine which level of the repository hierarchy this implements + ClassInfo repoInterface = index.getClassByName(DotName.createSimple(repo.getInterfaceName())); + boolean isMorphium = implementsInterface(repoInterface, MORPHIUM_REPOSITORY, index); + boolean isCrud = isMorphium || implementsInterface(repoInterface, CRUD_REPOSITORY, index); + boolean isBasic = isCrud || implementsInterface(repoInterface, BASIC_REPOSITORY, index); + + String superClass = QuarkusMorphiumRepository.class.getName(); + String signature = buildGenericSignature(superClass, repo.getInterfaceName(), + entityClassName, idClassName); + + try (ClassCreator cc = ClassCreator.builder() + .classOutput(classOutput) + .className(implClassName) + .superClass(superClass) + .interfaces(repo.getInterfaceName()) + .signature(signature) + .build()) { + + cc.addAnnotation("jakarta.enterprise.context.ApplicationScoped"); + + // Constructor: super(new RepositoryMetadata(Entity.class, Id.class, "idField")) + generateConstructor(cc, entityClassName, idClassName, idFieldName); + + // BasicRepository methods + if (isBasic) { + generateFindById(cc); + generateFindAll(cc); + generateFindAllPaged(cc); + // Check if repo declares findAll returning CursoredPage + if (repoInterface != null && hasFindAllCursored(repoInterface)) { + generateFindAllCursored(cc); + } + generateSave(cc); + generateSaveAll(cc); + generateDelete(cc); + generateDeleteById(cc); + generateDeleteAll(cc); + generateDeleteAllNoArg(cc); + } + + // CrudRepository methods + if (isCrud) { + generateInsert(cc); + generateInsertAll(cc); + generateUpdate(cc); + generateUpdateAll(cc); + } + + // MorphiumRepository methods + if (isMorphium) { + generateDistinct(cc); + generateMorphium(cc); + generateQuery(cc); + } + + // Custom query methods + if (repoInterface != null) { + Set entityFields = collectEntityFields( + index.getClassByName(DotName.createSimple(entityClassName)), index); + generateCustomQueryMethods(cc, repoInterface, index, entityClassName, entityFields, reflectiveClasses); + } + + log.infof("Generated repository implementation: %s", implClassName); + } + + // Register for reflection (native image) + reflectiveClasses.produce(ReflectiveClassBuildItem.builder(implClassName) + .constructors(true).methods(true).fields(true).build()); + } + + // -- Constructor generation -- + + private void generateConstructor(ClassCreator cc, + String entityClassName, + String idClassName, + String idFieldName) { + try (MethodCreator ctor = cc.getMethodCreator("", void.class)) { + ctor.setModifiers(Modifier.PUBLIC); + + ResultHandle entityClass = ctor.loadClassFromTCCL(entityClassName); + ResultHandle idClass = ctor.loadClassFromTCCL(idClassName); + ResultHandle idField = ctor.load(idFieldName); + + ResultHandle metadata = ctor.newInstance( + MethodDescriptor.ofConstructor(RepositoryMetadata.class, + Class.class, Class.class, String.class), + entityClass, idClass, idField); + + ctor.invokeSpecialMethod( + MethodDescriptor.ofMethod(QuarkusMorphiumRepository.class, + "", void.class, RepositoryMetadata.class), + ctor.getThis(), metadata); + + ctor.returnVoid(); + } + } + + // -- BasicRepository method generation -- + // Jakarta Data 1.0: findById returns Optional, save returns S, etc. + + private void generateFindById(ClassCreator cc) { + // Optional findById(K id) + try (MethodCreator mc = cc.getMethodCreator("findById", Optional.class, Object.class)) { + mc.setModifiers(Modifier.PUBLIC); + ResultHandle result = mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doFindById", Optional.class, Object.class), + mc.getThis(), mc.getMethodParam(0)); + mc.returnValue(result); + } + } + + private void generateFindAll(ClassCreator cc) { + // Stream findAll() + try (MethodCreator mc = cc.getMethodCreator("findAll", Stream.class)) { + mc.setModifiers(Modifier.PUBLIC); + ResultHandle result = mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doFindAll", Stream.class), + mc.getThis()); + mc.returnValue(result); + } + } + + private void generateFindAllPaged(ClassCreator cc) { + // Page findAll(PageRequest pageRequest, Order sortBy) + String pageClass = "jakarta.data.page.Page"; + String pageRequestClass = "jakarta.data.page.PageRequest"; + String orderClass = "jakarta.data.Order"; + try (MethodCreator mc = cc.getMethodCreator("findAll", pageClass, + pageRequestClass, orderClass)) { + mc.setModifiers(Modifier.PUBLIC); + ResultHandle result = mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doFindAllPaged", "jakarta.data.page.Page", + "jakarta.data.page.PageRequest", "jakarta.data.Order"), + mc.getThis(), mc.getMethodParam(0), mc.getMethodParam(1)); + mc.returnValue(result); + } + } + + private boolean hasFindAllCursored(ClassInfo repoInterface) { + for (MethodInfo method : repoInterface.methods()) { + if ("findAll".equals(method.name()) + && method.returnType().name().equals(CURSORED_PAGE_TYPE)) { + return true; + } + } + return false; + } + + private void generateFindAllCursored(ClassCreator cc) { + // CursoredPage findAll(PageRequest pageRequest, Order sortBy) + String cursoredPageClass = "jakarta.data.page.CursoredPage"; + String pageRequestClass = "jakarta.data.page.PageRequest"; + String orderClass = "jakarta.data.Order"; + try (MethodCreator mc = cc.getMethodCreator("findAll", cursoredPageClass, + pageRequestClass, orderClass)) { + mc.setModifiers(Modifier.PUBLIC); + ResultHandle result = mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doFindAllCursored", "jakarta.data.page.CursoredPage", + "jakarta.data.page.PageRequest", "jakarta.data.Order"), + mc.getThis(), mc.getMethodParam(0), mc.getMethodParam(1)); + mc.returnValue(result); + } + } + + private void warnIfMissingIdInOrderBy(MethodInfo method, String orderBySpec, + String entityClassName, Set entityFields) { + // Check if "id" field is included in the orderBy spec + Set orderByFields = new HashSet<>(); + for (String part : orderBySpec.split(",")) { + String[] fieldAndDir = part.split(":"); + orderByFields.add(fieldAndDir[0]); + } + if (!orderByFields.contains("id")) { + log.warnf("CursoredPage method %s.%s has @OrderBy %s but does not include the @Id field 'id'. " + + "Without a unique tie-breaker, cursor-based pagination may produce duplicate or missing results. " + + "Consider adding @OrderBy(\"id\") as last sort criterion.", + method.declaringClass().name(), method.name(), orderByFields); + } + } + + private void generateSave(ClassCreator cc) { + // S save(S entity) + try (MethodCreator mc = cc.getMethodCreator("save", Object.class, Object.class)) { + mc.setModifiers(Modifier.PUBLIC); + ResultHandle result = mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doSave", Object.class, Object.class), + mc.getThis(), mc.getMethodParam(0)); + mc.returnValue(result); + } + } + + private void generateSaveAll(ClassCreator cc) { + // List saveAll(List entities) + try (MethodCreator mc = cc.getMethodCreator("saveAll", List.class, List.class)) { + mc.setModifiers(Modifier.PUBLIC); + ResultHandle result = mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doSaveAll", List.class, List.class), + mc.getThis(), mc.getMethodParam(0)); + mc.returnValue(result); + } + } + + private void generateDelete(ClassCreator cc) { + // void delete(T entity) + try (MethodCreator mc = cc.getMethodCreator("delete", void.class, Object.class)) { + mc.setModifiers(Modifier.PUBLIC); + mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doDelete", void.class, Object.class), + mc.getThis(), mc.getMethodParam(0)); + mc.returnVoid(); + } + } + + private void generateDeleteById(ClassCreator cc) { + // void deleteById(K id) + try (MethodCreator mc = cc.getMethodCreator("deleteById", void.class, Object.class)) { + mc.setModifiers(Modifier.PUBLIC); + mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doDeleteById", void.class, Object.class), + mc.getThis(), mc.getMethodParam(0)); + mc.returnVoid(); + } + } + + private void generateDeleteAll(ClassCreator cc) { + // void deleteAll(List entities) + try (MethodCreator mc = cc.getMethodCreator("deleteAll", void.class, List.class)) { + mc.setModifiers(Modifier.PUBLIC); + mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doDeleteAll", void.class, List.class), + mc.getThis(), mc.getMethodParam(0)); + mc.returnVoid(); + } + } + + private void generateDeleteAllNoArg(ClassCreator cc) { + // void deleteAll() — no-arg, clears entire collection + try (MethodCreator mc = cc.getMethodCreator("deleteAll", void.class)) { + mc.setModifiers(Modifier.PUBLIC); + mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doDeleteAllNoArg", void.class), + mc.getThis()); + mc.returnVoid(); + } + } + + // -- CrudRepository method generation -- + + private void generateInsert(ClassCreator cc) { + try (MethodCreator mc = cc.getMethodCreator("insert", Object.class, Object.class)) { + mc.setModifiers(Modifier.PUBLIC); + ResultHandle result = mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doInsert", Object.class, Object.class), + mc.getThis(), mc.getMethodParam(0)); + mc.returnValue(result); + } + } + + private void generateInsertAll(ClassCreator cc) { + try (MethodCreator mc = cc.getMethodCreator("insertAll", List.class, List.class)) { + mc.setModifiers(Modifier.PUBLIC); + ResultHandle result = mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doInsertAll", List.class, List.class), + mc.getThis(), mc.getMethodParam(0)); + mc.returnValue(result); + } + } + + private void generateUpdate(ClassCreator cc) { + try (MethodCreator mc = cc.getMethodCreator("update", Object.class, Object.class)) { + mc.setModifiers(Modifier.PUBLIC); + ResultHandle result = mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doUpdate", Object.class, Object.class), + mc.getThis(), mc.getMethodParam(0)); + mc.returnValue(result); + } + } + + private void generateUpdateAll(ClassCreator cc) { + try (MethodCreator mc = cc.getMethodCreator("updateAll", List.class, List.class)) { + mc.setModifiers(Modifier.PUBLIC); + ResultHandle result = mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doUpdateAll", List.class, List.class), + mc.getThis(), mc.getMethodParam(0)); + mc.returnValue(result); + } + } + + // -- MorphiumRepository method generation -- + + private void generateDistinct(ClassCreator cc) { + // List distinct(String fieldName) + try (MethodCreator mc = cc.getMethodCreator("distinct", List.class, String.class)) { + mc.setModifiers(Modifier.PUBLIC); + ResultHandle result = mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doDistinct", List.class, String.class), + mc.getThis(), mc.getMethodParam(0)); + mc.returnValue(result); + } + } + + private void generateMorphium(ClassCreator cc) { + // Morphium morphium() + try (MethodCreator mc = cc.getMethodCreator("morphium", + "de.caluga.morphium.Morphium")) { + mc.setModifiers(Modifier.PUBLIC); + ResultHandle result = mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doMorphium", "de.caluga.morphium.Morphium"), + mc.getThis()); + mc.returnValue(result); + } + } + + private void generateQuery(ClassCreator cc) { + // Query query() + try (MethodCreator mc = cc.getMethodCreator("query", + "de.caluga.morphium.query.Query")) { + mc.setModifiers(Modifier.PUBLIC); + ResultHandle result = mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doQuery", "de.caluga.morphium.query.Query"), + mc.getThis()); + mc.returnValue(result); + } + } + + // ----------------------------------------------------------------- + // Custom query method generation + // ----------------------------------------------------------------- + + private void generateCustomQueryMethods(ClassCreator cc, + ClassInfo repoInterface, + IndexView index, + String entityClassName, + Set entityFields, + BuildProducer reflectiveClasses) { + // repoInterface.methods() only returns methods DECLARED directly on repoInterface -- + // an abstract method inherited from a custom super-interface (e.g. a shared + // "interface WithAudit { List findByAuditor(String who); }" that a @Repository + // interface extends alongside BasicRepository/CrudRepository) was invisible to this + // loop entirely, so it was never generated, never validated, and silently left + // abstract on the generated class -- surfacing only as an AbstractMethodError the + // first time a caller actually invoked it. Walk the full interface hierarchy (same + // interfaceTypes() traversal pattern used by resolveFromType()/implementsInterface() + // below) to also pick up methods declared on custom super-interfaces. The standard + // Jakarta Data / Morphium repository interfaces are excluded from this walk since + // their methods are the well-known CRUD methods already delegated to + // AbstractMorphiumRepository (see CRUD_METHODS) -- walking into them would otherwise + // flag e.g. BasicRepository's own abstract methods as "unsupported". + Map methodsBySignature = new LinkedHashMap<>(); + for (MethodInfo m : repoInterface.methods()) { + methodsBySignature.put(methodSignatureKey(m), m); + } + Set visitedInterfaces = new HashSet<>(); + visitedInterfaces.add(repoInterface.name().toString()); + collectInheritedCustomInterfaceMethods(repoInterface, index, visitedInterfaces, methodsBySignature); + + for (MethodInfo method : methodsBySignature.values()) { + String name = method.name(); + + // Skip standard CRUD methods and static methods + if (CRUD_METHODS.contains(name)) continue; + if (Modifier.isStatic(method.flags())) continue; + + // Skip anything that is NOT abstract: default methods and (Java 9+) private + // interface helper methods both have a body and need no generated implementation. + // Jandex's MethodInfo.isDefault() only recognizes "public && !static && !abstract" + // -- a private interface method (legal since Java 9, always has a body) is neither + // abstract NOR "default" by that definition, so guarding on isDefault() alone let a + // private helper fall through every check below and hit the "unsupported method" + // exception at the bottom of this loop, breaking the build for completely legal + // user code. Guarding on "not abstract" catches default methods, private methods, + // and static methods (already filtered above) alike. + if (!method.isAbstract()) continue; + + // toString()/equals()/hashCode() redeclared as abstract (a legal, if unusual, way + // to re-assert/narrow the Object contract in an interface) must NOT be generated + // here -- they are implemented by Object itself on any concrete class, Gizmo's + // ClassCreator already gives the generated class those inherited implementations, + // and none of the generators below (Query/Find/Delete/Insert/Save/Update/derived) + // has any notion of how to implement them. + if (isObjectMethodRedeclaration(method)) continue; + + // Phase 5: @Query with JDQL + if (method.hasAnnotation(QUERY_ANNOTATION)) { + generateQueryAnnotatedMethod(cc, method, entityClassName, index, reflectiveClasses); + continue; + } + + // Phase 4: Check for annotation-based methods first + if (method.hasAnnotation(FIND_ANNOTATION)) { + generateFindAnnotatedMethod(cc, method, entityClassName, entityFields); + continue; + } + if (method.hasAnnotation(DELETE_ANNOTATION)) { + generateDeleteAnnotatedMethod(cc, method, entityClassName); + continue; + } + if (method.hasAnnotation(INSERT_ANNOTATION)) { + generateInsertAnnotatedMethod(cc, method); + continue; + } + if (method.hasAnnotation(SAVE_ANNOTATION)) { + generateSaveAnnotatedMethod(cc, method); + continue; + } + if (method.hasAnnotation(UPDATE_ANNOTATION)) { + generateUpdateAnnotatedMethod(cc, method); + continue; + } + + // Phase 2: Try to parse as query derivation method + if (name.startsWith("findBy") || name.startsWith("countBy") + || name.startsWith("existsBy") || name.startsWith("deleteBy")) { + generateQueryMethod(cc, method, entityClassName, entityFields); + continue; + } + + // No recognized pattern matched this abstract method. Silently skipping it would + // leave an unimplemented abstract method on the generated class -- legal at class + // load time, but any call to it throws AbstractMethodError at first use in + // production. Fail the build instead, so an unsupported repository method is + // caught at build time, not by a user hitting the endpoint. + throw new IllegalStateException( + "Unsupported repository method " + method.declaringClass().name() + "." + name + + "() -- no @Query/@Find/@Delete/@Insert/@Save/@Update annotation and " + + "the method name doesn't match findBy*/countBy*/existsBy*/deleteBy*. " + + "Add one of these annotations, rename the method to match a supported " + + "derived-query pattern, or make the method default/static if it needs " + + "custom logic."); + } + } + + /** + * Builds a per-method key ({@code name(paramType1,paramType2,...)}) used to de-duplicate + * methods reachable via multiple interface paths (e.g. diamond inheritance) and to let a + * declaration closer to {@code repoInterface} take precedence over one further up the + * hierarchy with the same erased signature. + */ + private String methodSignatureKey(MethodInfo method) { + StringBuilder sb = new StringBuilder(method.name()).append('('); + for (int i = 0; i < method.parametersCount(); i++) { + if (i > 0) sb.append(','); + sb.append(method.parameterType(i).name()); + } + return sb.append(')').toString(); + } + + /** + * Walks the interface hierarchy above {@code current} (breadth over super-interfaces), + * adding any method not yet present in {@code methodsBySignature}. Standard Jakarta Data / + * Morphium repository interfaces (DataRepository, BasicRepository, CrudRepository, + * MorphiumRepository) are treated as a hierarchy dead-end: their own abstract methods are + * the well-known CRUD operations handled elsewhere (delegated to + * {@code AbstractMorphiumRepository}, see {@code CRUD_METHODS}), not "custom" methods that + * need generation/validation here, so this walk must not descend into them. + */ + private void collectInheritedCustomInterfaceMethods(ClassInfo current, IndexView index, + Set visitedInterfaces, + Map methodsBySignature) { + for (Type superType : current.interfaceTypes()) { + DotName superName = superType.name(); + if (superName.equals(DATA_REPOSITORY) || superName.equals(BASIC_REPOSITORY) + || superName.equals(CRUD_REPOSITORY) || superName.equals(MORPHIUM_REPOSITORY)) { + continue; + } + if (!visitedInterfaces.add(superName.toString())) { + continue; // already visited (diamond inheritance) -- avoid infinite recursion + } + ClassInfo superInfo = index.getClassByName(superName); + if (superInfo == null) { + continue; // not in the Jandex index (e.g. a JDK/library interface) -- nothing to generate + } + for (MethodInfo m : superInfo.methods()) { + methodsBySignature.putIfAbsent(methodSignatureKey(m), m); + } + collectInheritedCustomInterfaceMethods(superInfo, index, visitedInterfaces, methodsBySignature); + } + } + + /** + * True if {@code method} is an abstract redeclaration of {@code toString()}, {@code equals(Object)}, + * or {@code hashCode()} -- i.e. it has the exact name and parameter signature of one of the + * {@code java.lang.Object} methods a repository interface is legally allowed to re-assert as + * abstract. Any concrete class (including a Gizmo-generated one) inherits Object's + * implementation of these regardless, so such a redeclaration needs no method generation here. + */ + private boolean isObjectMethodRedeclaration(MethodInfo method) { + String name = method.name(); + int paramCount = method.parametersCount(); + if ("toString".equals(name) && paramCount == 0) return true; + if ("hashCode".equals(name) && paramCount == 0) return true; + if ("equals".equals(name) && paramCount == 1 + && method.parameterType(0).name().toString().equals("java.lang.Object")) { + return true; + } + return false; + } + + private void generateQueryMethod(ClassCreator cc, + MethodInfo method, + String entityClassName, + Set entityFields) { + String methodName = method.name(); + + // Build orderBy spec from @OrderBy annotations + String orderBySpec = buildOrderBySpec(method); + + // Detect async: CompletionStage → unwrap X as effective return type + Type returnType = method.returnType(); + boolean isAsync = isCompletionStage(returnType); + Type effectiveReturnType = isAsync ? unwrapCompletionStage(returnType) : returnType; + + // Strip "Async" suffix for parsing (e.g. "findByStatusAsync" → "findByStatus") + String parseableName = isAsync && methodName.endsWith("Async") + ? methodName.substring(0, methodName.length() - 5) : methodName; + + // Parse method name at build time to validate it + QueryDescriptor descriptor; + try { + descriptor = MethodNameParser.parse(parseableName, entityFields); + } catch (IllegalArgumentException e) { + throw new IllegalStateException( + "Failed to parse repository method " + method.declaringClass().name() + + "." + methodName + ": " + e.getMessage(), e); + } + + // Detect dynamic Sort/Order/PageRequest/Limit parameters -- same convention as + // generateFindAnnotatedMethod: these are matched by type, not by an annotation, so a + // derived findBy*/countBy*/existsBy*/deleteBy* method can accept them too (Jakarta Data + // does not restrict these parameter types to @Find methods). + int sortParamIndex = -1; + int orderParamIndex = -1; + int pageRequestParamIndex = -1; + int limitParamIndex = -1; + for (int i = 0; i < method.parametersCount(); i++) { + DotName paramTypeName = method.parameterType(i).name(); + if (paramTypeName.equals(SORT_TYPE)) { + sortParamIndex = i; + } else if (paramTypeName.equals(ORDER_TYPE)) { + orderParamIndex = i; + } else if (paramTypeName.equals(PAGE_REQUEST_TYPE)) { + pageRequestParamIndex = i; + } else if (paramTypeName.equals(LIMIT_TYPE)) { + limitParamIndex = i; + } + } + boolean hasDynamicParam = sortParamIndex >= 0 || orderParamIndex >= 0 + || pageRequestParamIndex >= 0 || limitParamIndex >= 0; + + // Reject Limit/PageRequest on countBy*/existsBy*/deleteBy* at BUILD TIME (see PR #267 + // review / regression fix in QueryMethodBridge.executeQuery): a dynamic Sort/Order + // argument on a non-FIND prefix is harmless to accept (there is no result list for it to + // reorder, so QueryMethodBridge simply ignores it), but a Limit or PageRequest is + // semantically meaningless there -- "the 3rd page of a delete" or "count, but only the + // first 10 matches" has no sensible definition, and none of the underlying Morphium + // primitives (countAll(), query.delete()) support a skip/limit-bounded variant anyway. + // Rather than silently ignoring the parameter (which would surprise a caller who wrote + // it expecting it to take effect) or raising an ambiguous runtime exception on first + // call, fail the build immediately with a clear message, same pattern as the other + // unsupported-signature checks in this method. + if (descriptor.prefix() != QueryDescriptor.Prefix.FIND + && (pageRequestParamIndex >= 0 || limitParamIndex >= 0)) { + throw new IllegalStateException( + "Unsupported repository method " + method.declaringClass().name() + "." + methodName + + "() -- a " + (pageRequestParamIndex >= 0 ? "PageRequest" : "Limit") + + " parameter is not supported on a " + descriptor.prefix().name().toLowerCase(Locale.ROOT) + + "By* method (" + methodName + "). Paging/limiting a count, existence check, or " + + "bulk delete is not sensibly definable. Remove the parameter, or restructure the " + + "method as a findBy* query and apply count()/isEmpty()/delete() logic in application " + + "code instead."); + } + + // Determine return type for the descriptor (based on effective/inner type) + boolean returnsPage = effectiveReturnType.name().equals(PAGE_TYPE); + boolean returnsOptional = isOptional(effectiveReturnType); + boolean returnsStream = isStream(effectiveReturnType); + boolean returnsSingle = !isList(effectiveReturnType) && !returnsStream + && !returnsOptional && !returnsPage + && descriptor.prefix() == QueryDescriptor.Prefix.FIND; + + // Build actual parameter type descriptors from the Jandex method info + String[] paramTypeNames = new String[method.parametersCount()]; + for (int i = 0; i < method.parametersCount(); i++) { + paramTypeNames[i] = toDescriptorName(method.parameterType(i)); + } + String returnTypeName = toDescriptorName(returnType); + + try (MethodCreator mc = cc.getMethodCreator( + MethodDescriptor.ofMethod(cc.getClassName(), methodName, + returnTypeName, paramTypeNames))) { + mc.setModifiers(Modifier.PUBLIC); + + // Build args array: Object[] args = new Object[] { param0, param1, ... } + ResultHandle argsArray = mc.newArray(Object.class, mc.load(method.parametersCount())); + for (int i = 0; i < method.parametersCount(); i++) { + ResultHandle param = mc.getMethodParam(i); + // Box primitives if needed + Type paramType = method.parameterType(i); + if (paramType.kind() == Type.Kind.PRIMITIVE) { + param = boxPrimitive(mc, param, paramType.asPrimitiveType()); + } + mc.writeArrayValue(argsArray, i, param); + } + + // Determine if this is a deleteBy* returning boolean (needs count-to-boolean conversion) + boolean returnsBoolean = effectiveReturnType.kind() == Type.Kind.PRIMITIVE + && effectiveReturnType.asPrimitiveType().primitive() == PrimitiveType.Primitive.BOOLEAN + && descriptor.prefix() == QueryDescriptor.Prefix.DELETE; + + ResultHandle methodNameHandle = mc.load(parseableName); + ResultHandle returnsSingleHandle = mc.load(returnsSingle); + ResultHandle returnsOptionalHandle = mc.load(returnsOptional); + ResultHandle returnsBooleanHandle = mc.load(returnsBoolean); + ResultHandle returnsStreamHandle = mc.load(returnsStream); + ResultHandle orderBySpecHandle = mc.load(orderBySpec); + ResultHandle thisHandle = mc.getThis(); + + String bridgeMethod = isAsync ? "executeQueryAsync" : "executeQuery"; + Class bridgeReturnType = isAsync ? CompletionStage.class : Object.class; + + // Handle void return type (e.g., void deleteByStatus(...)) + if (returnType.kind() == Type.Kind.VOID) { + mc.invokeStaticMethod( + MethodDescriptor.ofMethod( + QueryMethodBridge.class, + "executeQuery", + Object.class, + AbstractMorphiumRepository.class, + String.class, + Object[].class, + boolean.class, + boolean.class, + boolean.class, + boolean.class, + String.class), + thisHandle, methodNameHandle, argsArray, returnsSingleHandle, + returnsOptionalHandle, returnsBooleanHandle, returnsStreamHandle, + orderBySpecHandle); + mc.returnVoid(); + } else { + ResultHandle result; + if (hasDynamicParam) { + ResultHandle sortIdxHandle = mc.load(sortParamIndex); + ResultHandle orderIdxHandle = mc.load(orderParamIndex); + ResultHandle pageRequestIdxHandle = mc.load(pageRequestParamIndex); + ResultHandle limitIdxHandle = mc.load(limitParamIndex); + result = mc.invokeStaticMethod( + MethodDescriptor.ofMethod( + QueryMethodBridge.class, + bridgeMethod, + bridgeReturnType, + AbstractMorphiumRepository.class, + String.class, + Object[].class, + boolean.class, + boolean.class, + boolean.class, + boolean.class, + String.class, + int.class, + int.class, + int.class, + int.class), + thisHandle, methodNameHandle, argsArray, returnsSingleHandle, + returnsOptionalHandle, returnsBooleanHandle, returnsStreamHandle, + orderBySpecHandle, sortIdxHandle, orderIdxHandle, + pageRequestIdxHandle, limitIdxHandle); + } else { + result = mc.invokeStaticMethod( + MethodDescriptor.ofMethod( + QueryMethodBridge.class, + bridgeMethod, + bridgeReturnType, + AbstractMorphiumRepository.class, + String.class, + Object[].class, + boolean.class, + boolean.class, + boolean.class, + boolean.class, + String.class), + thisHandle, methodNameHandle, argsArray, returnsSingleHandle, + returnsOptionalHandle, returnsBooleanHandle, returnsStreamHandle, + orderBySpecHandle); + } + + // Unbox/cast the result to the declared return type (skip for async — returns CompletionStage) + if (!isAsync && returnType.kind() == Type.Kind.PRIMITIVE) { + result = unboxPrimitive(mc, result, returnType.asPrimitiveType()); + } + + mc.returnValue(result); + } + } + + log.infof("Generated query-derivation method: %s.%s%s → %s (conditions: %d, orderBy: %s)", + method.declaringClass().name(), methodName, + isAsync ? " (async)" : "", + descriptor.prefix().name().toLowerCase(Locale.ROOT), + descriptor.conditions().size(), + orderBySpec.isEmpty() ? "none" : orderBySpec); + } + + private String toDescriptorName(Type type) { + if (type.kind() == Type.Kind.PRIMITIVE) { + return type.asPrimitiveType().primitive().name().toLowerCase(Locale.ROOT); + } + return type.name().toString(); + } + + private ResultHandle boxPrimitive(MethodCreator mc, ResultHandle value, + PrimitiveType ptype) { + return switch (ptype.primitive()) { + case DOUBLE -> mc.invokeStaticMethod( + MethodDescriptor.ofMethod(Double.class, "valueOf", Double.class, double.class), value); + case FLOAT -> mc.invokeStaticMethod( + MethodDescriptor.ofMethod(Float.class, "valueOf", Float.class, float.class), value); + case LONG -> mc.invokeStaticMethod( + MethodDescriptor.ofMethod(Long.class, "valueOf", Long.class, long.class), value); + case INT -> mc.invokeStaticMethod( + MethodDescriptor.ofMethod(Integer.class, "valueOf", Integer.class, int.class), value); + case SHORT -> mc.invokeStaticMethod( + MethodDescriptor.ofMethod(Short.class, "valueOf", Short.class, short.class), value); + case BYTE -> mc.invokeStaticMethod( + MethodDescriptor.ofMethod(Byte.class, "valueOf", Byte.class, byte.class), value); + case CHAR -> mc.invokeStaticMethod( + MethodDescriptor.ofMethod(Character.class, "valueOf", Character.class, char.class), value); + case BOOLEAN -> mc.invokeStaticMethod( + MethodDescriptor.ofMethod(Boolean.class, "valueOf", Boolean.class, boolean.class), value); + default -> value; + }; + } + + private ResultHandle unboxPrimitive(MethodCreator mc, ResultHandle value, + PrimitiveType ptype) { + return switch (ptype.primitive()) { + case LONG -> { + ResultHandle cast = mc.checkCast(value, Long.class); + yield mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(Long.class, "longValue", long.class), cast); + } + case DOUBLE -> { + ResultHandle cast = mc.checkCast(value, Double.class); + yield mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(Double.class, "doubleValue", double.class), cast); + } + case INT -> { + ResultHandle cast = mc.checkCast(value, Integer.class); + yield mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(Integer.class, "intValue", int.class), cast); + } + case BOOLEAN -> { + ResultHandle cast = mc.checkCast(value, Boolean.class); + yield mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(Boolean.class, "booleanValue", boolean.class), cast); + } + case FLOAT -> { + ResultHandle cast = mc.checkCast(value, Float.class); + yield mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(Float.class, "floatValue", float.class), cast); + } + case SHORT -> { + ResultHandle cast = mc.checkCast(value, Short.class); + yield mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(Short.class, "shortValue", short.class), cast); + } + case BYTE -> { + ResultHandle cast = mc.checkCast(value, Byte.class); + yield mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(Byte.class, "byteValue", byte.class), cast); + } + case CHAR -> { + ResultHandle cast = mc.checkCast(value, Character.class); + yield mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(Character.class, "charValue", char.class), cast); + } + default -> value; + }; + } + + // ----------------------------------------------------------------- + // Phase 4: @Find, @Delete, @Insert, @Save, @Update generation + // ----------------------------------------------------------------- + + /** + * Generates implementation for a {@code @Find} annotated method. + * Parameters annotated with {@code @By} become equality conditions. + * Special parameters (Sort, Order, PageRequest, Limit) are detected by type. + */ + private void generateFindAnnotatedMethod(ClassCreator cc, MethodInfo method, + String entityClassName, + Set entityFields) { + // Build conditions spec and identify special params + StringBuilder conditionsSpec = new StringBuilder(); + int conditionCount = 0; + int sortParamIndex = -1; + int orderParamIndex = -1; + int pageRequestParamIndex = -1; + int limitParamIndex = -1; + + for (int i = 0; i < method.parametersCount(); i++) { + Type paramType = method.parameterType(i); + DotName paramTypeName = paramType.name(); + + // Check for special parameter types + if (paramTypeName.equals(SORT_TYPE)) { + sortParamIndex = i; + continue; + } + if (paramTypeName.equals(ORDER_TYPE)) { + orderParamIndex = i; + continue; + } + if (paramTypeName.equals(PAGE_REQUEST_TYPE)) { + pageRequestParamIndex = i; + continue; + } + if (paramTypeName.equals(LIMIT_TYPE)) { + limitParamIndex = i; + continue; + } + + // Check for @By annotation; fall back to method parameter name + // if compiled with -parameters (Jakarta Data spec §4.6.1) + AnnotationInstance byAnn = method.parameters().get(i).annotation(BY_ANNOTATION); + String fieldName = null; + if (byAnn != null) { + fieldName = byAnn.value().asString(); + } else { + String methodParamName = method.parameters().get(i).name(); + if (methodParamName != null) { + fieldName = methodParamName; + } + } + if (fieldName != null) { + // Validate field exists — for dot-notation paths (e.g. "category.name") + // only validate the root segment against entity fields + if (entityFields != null && !entityFields.isEmpty() && !"id(this)".equals(fieldName)) { + String rootField = fieldName.contains(".") ? fieldName.substring(0, fieldName.indexOf('.')) : fieldName; + if (!entityFields.contains(rootField)) { + log.warnf("@By(\"%s\") on method %s.%s param %s — field '%s' not found on entity %s. " + + "Will use as-is (may be resolved at runtime via @Property).", + fieldName, method.declaringClass().name(), method.name(), i, rootField, entityClassName); + } + } + if (conditionsSpec.length() > 0) conditionsSpec.append(","); + conditionsSpec.append(fieldName).append(":").append(i); + conditionCount++; + } + } + + // Build orderBy spec from @OrderBy annotations + String orderBySpec = buildOrderBySpec(method); + + // Detect async: CompletionStage → unwrap X as effective return type + Type returnType = method.returnType(); + boolean isAsync = isCompletionStage(returnType); + Type effectiveReturnType = isAsync ? unwrapCompletionStage(returnType) : returnType; + + // Determine return type (based on effective/inner type) + boolean returnsOptional = isOptional(effectiveReturnType); + boolean returnsCursoredPage = effectiveReturnType.name().equals(CURSORED_PAGE_TYPE); + boolean returnsStream = isStream(effectiveReturnType); + boolean returnsSingle = !isList(effectiveReturnType) && !returnsStream + && !returnsOptional + && !effectiveReturnType.name().equals(PAGE_TYPE) + && !returnsCursoredPage; + + // Warn if CursoredPage method lacks @Id field in @OrderBy + if (returnsCursoredPage && !orderBySpec.isEmpty()) { + warnIfMissingIdInOrderBy(method, orderBySpec, entityClassName, entityFields); + } + + // Build parameter type descriptors + String[] paramTypeNames = new String[method.parametersCount()]; + for (int i = 0; i < method.parametersCount(); i++) { + paramTypeNames[i] = toDescriptorName(method.parameterType(i)); + } + String returnTypeName = toDescriptorName(returnType); + + String bridgeMethod = isAsync ? "executeFindAsync" : "executeFind"; + Class bridgeReturnType = isAsync ? CompletionStage.class : Object.class; + + try (MethodCreator mc = cc.getMethodCreator( + MethodDescriptor.ofMethod(cc.getClassName(), method.name(), + returnTypeName, paramTypeNames))) { + mc.setModifiers(Modifier.PUBLIC); + + // Build args array + ResultHandle argsArray = mc.newArray(Object.class, mc.load(method.parametersCount())); + for (int i = 0; i < method.parametersCount(); i++) { + ResultHandle param = mc.getMethodParam(i); + Type paramType = method.parameterType(i); + if (paramType.kind() == Type.Kind.PRIMITIVE) { + param = boxPrimitive(mc, param, paramType.asPrimitiveType()); + } + mc.writeArrayValue(argsArray, i, param); + } + + ResultHandle result = mc.invokeStaticMethod( + MethodDescriptor.ofMethod( + FindMethodBridge.class, + bridgeMethod, + bridgeReturnType, + AbstractMorphiumRepository.class, + String.class, String.class, + int.class, int.class, int.class, int.class, + Object[].class, boolean.class, boolean.class, boolean.class, boolean.class), + mc.getThis(), + mc.load(conditionsSpec.toString()), + mc.load(orderBySpec), + mc.load(sortParamIndex), + mc.load(orderParamIndex), + mc.load(pageRequestParamIndex), + mc.load(limitParamIndex), + argsArray, + mc.load(returnsSingle), + mc.load(returnsOptional), + mc.load(returnsCursoredPage), + mc.load(returnsStream)); + + if (!isAsync && returnType.kind() == Type.Kind.PRIMITIVE) { + result = unboxPrimitive(mc, result, returnType.asPrimitiveType()); + } + + mc.returnValue(result); + } + + log.infof("Generated @Find method: %s.%s%s → find (conditions: %d, orderBy: %s)", + method.declaringClass().name(), method.name(), + isAsync ? " (async)" : "", + conditionCount, + orderBySpec.isEmpty() ? "none" : orderBySpec); + } + + /** + * Generates implementation for a {@code @Delete} annotated method. + * If the method has {@code @By} parameters, deletes matching entities. + * If the method has a single entity parameter, delegates to doDelete(). + */ + private void generateDeleteAnnotatedMethod(ClassCreator cc, MethodInfo method, + String entityClassName) { + // Check if this is entity-parameter delete or @By-condition delete + boolean hasByParams = false; + StringBuilder conditionsSpec = new StringBuilder(); + for (int i = 0; i < method.parametersCount(); i++) { + // Check for @By annotation; fall back to method parameter name if compiled with + // -parameters (Jakarta Data spec §4.6.1) -- same pattern as generateFindAnnotatedMethod. + // Without this fallback, a @Delete method relying on parameter names alone gets + // hasByParams=false, is (mis)treated as an entity-parameter delete, and ends up + // calling doDelete(someString) at runtime -- attempting to delete a String as if + // it were an entity. + // + // Exception: the parameter-name fallback must NOT fire for an entity-shaped + // parameter (the entity itself, an array of it, or a List/Collection/Iterable of + // it). Jakarta Data defines such a parameter as a lifecycle delete-by-entity + // parameter, not a condition (jakarta.data-api Delete javadoc). Applying the + // fallback there previously built a bogus query such as {customer: }, which + // never matches anything -- query.delete() silently deletes zero documents and the + // method returns normally: a silent data-loss bug. An explicit @By annotation is + // always honoured, even on an entity-typed parameter, since the developer opted in + // deliberately. + AnnotationInstance byAnn = method.parameters().get(i).annotation(BY_ANNOTATION); + String fieldName = null; + if (byAnn != null) { + fieldName = byAnn.value().asString(); + } else if (!isEntityParameter(method.parameterType(i), entityClassName)) { + String methodParamName = method.parameters().get(i).name(); + if (methodParamName != null) { + fieldName = methodParamName; + } + } + if (fieldName != null) { + hasByParams = true; + if (conditionsSpec.length() > 0) conditionsSpec.append(","); + conditionsSpec.append(fieldName).append(":").append(i); + } + } + + // Jakarta Data does not specify mixing an entity delete-lifecycle parameter with + // parameter/@By conditions in the same @Delete method (jakarta.data-api Delete javadoc: + // a method has either exactly one such entity parameter, or condition parameters). Since + // there is no defined semantics for the mix, reject it at build time rather than silently + // picking one interpretation. + boolean hasEntityParam = false; + for (int i = 0; i < method.parametersCount(); i++) { + if (isEntityParameter(method.parameterType(i), entityClassName)) { + hasEntityParam = true; + break; + } + } + if (hasEntityParam && hasByParams) { + throw new IllegalStateException( + "Unsupported @Delete method " + method.declaringClass().name() + "." + + method.name() + "() -- mixes an entity-typed parameter with " + + "parameter/@By-condition parameters. Jakarta Data does not specify " + + "this combination: a @Delete method must have either exactly one " + + "entity/List/entity[] lifecycle parameter, or " + + "parameter/@By-condition parameters, but not both. Split this into " + + "two separate methods."); + } + + String[] paramTypeNames = new String[method.parametersCount()]; + for (int i = 0; i < method.parametersCount(); i++) { + paramTypeNames[i] = toDescriptorName(method.parameterType(i)); + } + String returnTypeName = toDescriptorName(method.returnType()); + + if (hasByParams) { + // Delete by conditions + Type returnType = method.returnType(); + boolean returnsCount = returnType.kind() == Type.Kind.PRIMITIVE + && (returnType.asPrimitiveType().primitive() == PrimitiveType.Primitive.LONG + || returnType.asPrimitiveType().primitive() == PrimitiveType.Primitive.INT); + + // Jakarta Data restricts a parameter-based (i.e. condition-driven) @Delete method to + // void, int, or long return types (jakarta.data-api 1.0.1, @Delete javadoc: "the + // return type must be void, or a numeric type... int or long"). Any other return + // type -- boolean, Integer, Long, or anything else -- previously fell through to the + // "void" branch below: the generated bytecode called the void-returning + // executeAnnotatedDelete bridge and then executed a bare "return" for a method whose + // descriptor promises a non-void value, which is invalid bytecode and throws + // VerifyError the first time the class is loaded, not at build time. Reject it here + // with a clear build-time message instead. + boolean returnsVoid = returnType.kind() == Type.Kind.VOID; + if (!returnsVoid && !returnsCount) { + throw new IllegalStateException( + "Unsupported @Delete method " + method.declaringClass().name() + "." + + method.name() + "() -- return type " + returnType + + " is not supported for a parameter/@By-condition @Delete method. " + + "Jakarta Data only allows void, int, or long here (the deleted-record " + + "count for int/long, or the count discarded for void). " + + "Change the return type to void, int, or long."); + } + try (MethodCreator mc = cc.getMethodCreator( + MethodDescriptor.ofMethod(cc.getClassName(), method.name(), + returnTypeName, paramTypeNames))) { + mc.setModifiers(Modifier.PUBLIC); + + ResultHandle argsArray = mc.newArray(Object.class, mc.load(method.parametersCount())); + for (int i = 0; i < method.parametersCount(); i++) { + ResultHandle param = mc.getMethodParam(i); + Type paramType = method.parameterType(i); + if (paramType.kind() == Type.Kind.PRIMITIVE) { + param = boxPrimitive(mc, param, paramType.asPrimitiveType()); + } + mc.writeArrayValue(argsArray, i, param); + } + + if (returnsCount) { + // int/long: Jakarta Data requires the deleted-record count to be returned + // (@Delete Javadoc: "If the method return type is int or long, the method + // must return the number of deleted records"). + ResultHandle count = mc.invokeStaticMethod( + MethodDescriptor.ofMethod( + FindMethodBridge.class, + "executeAnnotatedDeleteCounted", + long.class, + AbstractMorphiumRepository.class, + String.class, Object[].class), + mc.getThis(), + mc.load(conditionsSpec.toString()), + argsArray); + if (returnType.asPrimitiveType().primitive() == PrimitiveType.Primitive.INT) { + ResultHandle asInt = mc.invokeStaticMethod( + MethodDescriptor.ofMethod(Math.class, "toIntExact", int.class, long.class), + count); + mc.returnValue(asInt); + } else { + mc.returnValue(count); + } + } else { + // void: Jakarta Data permits (and this is the common case) discarding the count. + mc.invokeStaticMethod( + MethodDescriptor.ofMethod( + FindMethodBridge.class, + "executeAnnotatedDelete", + void.class, + AbstractMorphiumRepository.class, + String.class, Object[].class), + mc.getThis(), + mc.load(conditionsSpec.toString()), + argsArray); + mc.returnVoid(); + } + } + } else { + // Single entity parameter → delegate to doDelete + try (MethodCreator mc = cc.getMethodCreator( + MethodDescriptor.ofMethod(cc.getClassName(), method.name(), + returnTypeName, paramTypeNames))) { + mc.setModifiers(Modifier.PUBLIC); + mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doDelete", void.class, Object.class), + mc.getThis(), mc.getMethodParam(0)); + mc.returnVoid(); + } + } + + log.infof("Generated @Delete method: %s.%s", method.declaringClass().name(), method.name()); + } + + /** + * Generates implementation for an {@code @Insert} annotated method. + * Delegates to doInsert() / doInsertAll(). + */ + private void generateInsertAnnotatedMethod(ClassCreator cc, MethodInfo method) { + String[] paramTypeNames = new String[method.parametersCount()]; + for (int i = 0; i < method.parametersCount(); i++) { + paramTypeNames[i] = toDescriptorName(method.parameterType(i)); + } + String returnTypeName = toDescriptorName(method.returnType()); + boolean isList = isList(method.parameterType(0)); + + try (MethodCreator mc = cc.getMethodCreator( + MethodDescriptor.ofMethod(cc.getClassName(), method.name(), + returnTypeName, paramTypeNames))) { + mc.setModifiers(Modifier.PUBLIC); + + if (isList) { + ResultHandle result = mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doInsertAll", List.class, List.class), + mc.getThis(), mc.getMethodParam(0)); + mc.returnValue(result); + } else { + ResultHandle result = mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doInsert", Object.class, Object.class), + mc.getThis(), mc.getMethodParam(0)); + mc.returnValue(result); + } + } + + log.infof("Generated @Insert method: %s.%s", method.declaringClass().name(), method.name()); + } + + /** + * Generates implementation for a {@code @Save} annotated method. + * Delegates to doSave() / doSaveAll(). + */ + private void generateSaveAnnotatedMethod(ClassCreator cc, MethodInfo method) { + String[] paramTypeNames = new String[method.parametersCount()]; + for (int i = 0; i < method.parametersCount(); i++) { + paramTypeNames[i] = toDescriptorName(method.parameterType(i)); + } + String returnTypeName = toDescriptorName(method.returnType()); + boolean isList = isList(method.parameterType(0)); + + try (MethodCreator mc = cc.getMethodCreator( + MethodDescriptor.ofMethod(cc.getClassName(), method.name(), + returnTypeName, paramTypeNames))) { + mc.setModifiers(Modifier.PUBLIC); + + if (isList) { + ResultHandle result = mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doSaveAll", List.class, List.class), + mc.getThis(), mc.getMethodParam(0)); + mc.returnValue(result); + } else { + ResultHandle result = mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doSave", Object.class, Object.class), + mc.getThis(), mc.getMethodParam(0)); + mc.returnValue(result); + } + } + + log.infof("Generated @Save method: %s.%s", method.declaringClass().name(), method.name()); + } + + /** + * Generates implementation for an {@code @Update} annotated method. + * Delegates to doUpdate() / doUpdateAll(). + */ + private void generateUpdateAnnotatedMethod(ClassCreator cc, MethodInfo method) { + String[] paramTypeNames = new String[method.parametersCount()]; + for (int i = 0; i < method.parametersCount(); i++) { + paramTypeNames[i] = toDescriptorName(method.parameterType(i)); + } + String returnTypeName = toDescriptorName(method.returnType()); + boolean isList = isList(method.parameterType(0)); + + try (MethodCreator mc = cc.getMethodCreator( + MethodDescriptor.ofMethod(cc.getClassName(), method.name(), + returnTypeName, paramTypeNames))) { + mc.setModifiers(Modifier.PUBLIC); + + if (isList) { + ResultHandle result = mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doUpdateAll", List.class, List.class), + mc.getThis(), mc.getMethodParam(0)); + mc.returnValue(result); + } else { + ResultHandle result = mc.invokeVirtualMethod( + MethodDescriptor.ofMethod(AbstractMorphiumRepository.class, + "doUpdate", Object.class, Object.class), + mc.getThis(), mc.getMethodParam(0)); + mc.returnValue(result); + } + } + + log.infof("Generated @Update method: %s.%s", method.declaringClass().name(), method.name()); + } + + /** + * Builds the orderBy spec string from {@code @OrderBy} annotations on a method. + */ + private String buildOrderBySpec(MethodInfo method) { + StringBuilder sb = new StringBuilder(); + + // Check for @OrderBy.List (repeatable container) + AnnotationInstance listAnn = method.annotation(ORDER_BY_LIST_ANNOTATION); + if (listAnn != null) { + for (AnnotationInstance orderBy : listAnn.value().asNestedArray()) { + if (sb.length() > 0) sb.append(","); + sb.append(orderBy.value().asString()); + sb.append(":"); + sb.append(isDescending(orderBy) ? "DESC" : "ASC"); + } + return sb.toString(); + } + + // Check for single @OrderBy + AnnotationInstance orderBy = method.annotation(ORDER_BY_ANNOTATION); + if (orderBy != null) { + sb.append(orderBy.value().asString()); + sb.append(":"); + sb.append(isDescending(orderBy) ? "DESC" : "ASC"); + } + + return sb.toString(); + } + + private boolean isDescending(AnnotationInstance orderBy) { + var val = orderBy.value("descending"); + return val != null && val.asBoolean(); + } + + // ----------------------------------------------------------------- + // Phase 5: @Query with JDQL generation + // ----------------------------------------------------------------- + + /** + * Generates implementation for a {@code @Query} annotated method. + * Extracts the JDQL string and builds a {@code @Param} name-to-index mapping. + * Special parameters (Sort, Order, PageRequest, Limit) are detected by type. + */ + private void generateQueryAnnotatedMethod(ClassCreator cc, MethodInfo method, + String entityClassName, + IndexView index, + BuildProducer reflectiveClasses) { + // Extract JDQL string from @Query annotation + AnnotationInstance queryAnn = method.annotation(QUERY_ANNOTATION); + String jdql = queryAnn.value().asString(); + + // Build-time validation: reject MongoDB JSON syntax and positional parameters + validateJdqlSyntax(jdql, method); + + // Build @Param name-to-index mapping and detect special params + StringBuilder paramMapSpec = new StringBuilder(); + int sortParamIndex = -1; + int orderParamIndex = -1; + int pageRequestParamIndex = -1; + int limitParamIndex = -1; + + List params = method.parameters(); + for (int i = 0; i < method.parametersCount(); i++) { + Type paramType = method.parameterType(i); + DotName paramTypeName = paramType.name(); + + // Check for special parameter types + if (paramTypeName.equals(SORT_TYPE)) { + sortParamIndex = i; + continue; + } + if (paramTypeName.equals(ORDER_TYPE)) { + orderParamIndex = i; + continue; + } + if (paramTypeName.equals(PAGE_REQUEST_TYPE)) { + pageRequestParamIndex = i; + continue; + } + if (paramTypeName.equals(LIMIT_TYPE)) { + limitParamIndex = i; + continue; + } + + // Check for @Param annotation; fall back to method parameter name + // if compiled with -parameters (Jakarta Data spec §4.6.1) + AnnotationInstance paramAnn = params.get(i).annotation(PARAM_ANNOTATION); + String paramName = null; + if (paramAnn != null) { + paramName = paramAnn.value().asString(); + } else { + String methodParamName = params.get(i).name(); + if (methodParamName != null) { + paramName = methodParamName; + } + } + if (paramName != null) { + if (paramMapSpec.length() > 0) paramMapSpec.append(","); + paramMapSpec.append(paramName).append(":").append(i); + } + } + + // Build orderBy spec from @OrderBy annotations (used by CursoredPage) + String orderBySpec = buildOrderBySpec(method); + + // Detect async: CompletionStage → unwrap X as effective return type + Type returnType = method.returnType(); + boolean isAsync = isCompletionStage(returnType); + Type effectiveReturnType = isAsync ? unwrapCompletionStage(returnType) : returnType; + + // Determine return type characteristics (based on effective/inner type) + boolean returnsOptional = isOptional(effectiveReturnType); + boolean returnsCursoredPage = effectiveReturnType.name().equals(CURSORED_PAGE_TYPE); + boolean returnsStream = isStream(effectiveReturnType); + boolean returnsSingle = !isList(effectiveReturnType) && !returnsStream + && !returnsOptional + && !effectiveReturnType.name().equals(PAGE_TYPE) + && !returnsCursoredPage; + boolean returnsCount = effectiveReturnType.kind() == Type.Kind.PRIMITIVE + && effectiveReturnType.asPrimitiveType().primitive() == PrimitiveType.Primitive.LONG; + boolean returnsBoolean = effectiveReturnType.kind() == Type.Kind.PRIMITIVE + && effectiveReturnType.asPrimitiveType().primitive() == PrimitiveType.Primitive.BOOLEAN; + + // Detect Record return type for GROUP BY support + String resultRecordClass = null; + if (isList(effectiveReturnType) && effectiveReturnType.kind() == Type.Kind.PARAMETERIZED_TYPE) { + Type innerType = effectiveReturnType.asParameterizedType().arguments().get(0); + DotName innerTypeName = innerType.name(); + if (!innerTypeName.toString().equals(entityClassName)) { + ClassInfo innerClassInfo = index.getClassByName(innerTypeName); + if (innerClassInfo != null + && innerClassInfo.superName() != null + && innerClassInfo.superName().toString().equals("java.lang.Record")) { + resultRecordClass = innerTypeName.toString(); + reflectiveClasses.produce( + ReflectiveClassBuildItem.builder(resultRecordClass) + .constructors(true).methods(true).build()); + } + } + } + + // Also detect Record for Page return types (GROUP BY pagination) + if (resultRecordClass == null + && effectiveReturnType.name().equals(PAGE_TYPE) + && effectiveReturnType.kind() == Type.Kind.PARAMETERIZED_TYPE) { + Type innerType = effectiveReturnType.asParameterizedType().arguments().get(0); + DotName innerTypeName = innerType.name(); + if (!innerTypeName.toString().equals(entityClassName)) { + ClassInfo innerClassInfo = index.getClassByName(innerTypeName); + if (innerClassInfo != null + && innerClassInfo.superName() != null + && innerClassInfo.superName().toString().equals("java.lang.Record")) { + resultRecordClass = innerTypeName.toString(); + reflectiveClasses.produce( + ReflectiveClassBuildItem.builder(resultRecordClass) + .constructors(true).methods(true).build()); + } + } + } + + // Build parameter type descriptors + String[] paramTypeNames = new String[method.parametersCount()]; + for (int i = 0; i < method.parametersCount(); i++) { + paramTypeNames[i] = toDescriptorName(method.parameterType(i)); + } + String returnTypeName = toDescriptorName(returnType); + + String bridgeMethod = isAsync ? "executeJdqlAsync" : "executeJdql"; + Class bridgeReturnType = isAsync ? CompletionStage.class : Object.class; + + try (MethodCreator mc = cc.getMethodCreator( + MethodDescriptor.ofMethod(cc.getClassName(), method.name(), + returnTypeName, paramTypeNames))) { + mc.setModifiers(Modifier.PUBLIC); + + // Build args array + ResultHandle argsArray = mc.newArray(Object.class, mc.load(method.parametersCount())); + for (int i = 0; i < method.parametersCount(); i++) { + ResultHandle param = mc.getMethodParam(i); + Type paramType = method.parameterType(i); + if (paramType.kind() == Type.Kind.PRIMITIVE) { + param = boxPrimitive(mc, param, paramType.asPrimitiveType()); + } + mc.writeArrayValue(argsArray, i, param); + } + + ResultHandle result = mc.invokeStaticMethod( + MethodDescriptor.ofMethod( + JdqlMethodBridge.class, + bridgeMethod, + bridgeReturnType, + AbstractMorphiumRepository.class, + String.class, String.class, + int.class, int.class, int.class, int.class, + Object[].class, + boolean.class, boolean.class, boolean.class, boolean.class, + boolean.class, String.class, boolean.class, + String.class), + mc.getThis(), + mc.load(jdql), + mc.load(paramMapSpec.toString()), + mc.load(sortParamIndex), + mc.load(orderParamIndex), + mc.load(pageRequestParamIndex), + mc.load(limitParamIndex), + argsArray, + mc.load(returnsSingle), + mc.load(returnsCount), + mc.load(returnsBoolean), + mc.load(returnsOptional), + mc.load(returnsCursoredPage), + mc.load(orderBySpec), + mc.load(returnsStream), + resultRecordClass != null ? mc.load(resultRecordClass) : mc.loadNull()); + + // Unbox primitive return types (skip for async — returns CompletionStage) + if (!isAsync && returnType.kind() == Type.Kind.PRIMITIVE) { + result = unboxPrimitive(mc, result, returnType.asPrimitiveType()); + } + + mc.returnValue(result); + } + + log.infof("Generated @Query method: %s.%s%s → JDQL: %s", method.declaringClass().name(), method.name(), + isAsync ? " (async)" : "", jdql == null || jdql.isBlank() ? "(no filter / find all)" : jdql); + } + + /** + * Validates that a {@code @Query} annotation value uses JDQL syntax with named parameters + * ({@code :paramName}), not MongoDB JSON syntax or JPA-style positional parameters ({@code ?1}). + * + * @throws IllegalStateException if the query uses unsupported syntax + */ + private void validateJdqlSyntax(String jdql, MethodInfo method) { + if (jdql == null || jdql.isBlank()) { + return; + } + String trimmed = jdql.trim(); + // Detect MongoDB JSON syntax: starts with { or contains $-operators + if (trimmed.startsWith("{")) { + throw new IllegalStateException( + "@Query on " + method.declaringClass().name() + "." + method.name() + + " uses MongoDB JSON syntax: \"" + jdql + "\". " + + "Jakarta Data @Query requires JDQL syntax with named parameters (:paramName). " + + "Example: @Query(\"WHERE field = :param AND other >= :min\")"); + } + // Detect JPA-style positional parameters: ?1, ?2, etc. + if (trimmed.matches(".*\\?\\d+.*")) { + throw new IllegalStateException( + "@Query on " + method.declaringClass().name() + "." + method.name() + + " uses positional parameters (?1, ?2, ...): \"" + jdql + "\". " + + "Jakarta Data @Query requires named parameters (:paramName). " + + "Example: @Query(\"WHERE field = :param\") with @Param(\"param\") on method parameters."); + } + } + + // ----------------------------------------------------------------- + // Type resolution helpers + // ----------------------------------------------------------------- + + private record TypeParameters(DotName entityType, DotName idType) {} + + private TypeParameters resolveEntityAndIdTypes(ClassInfo repoClass, IndexView index) { + for (Type superInterface : repoClass.interfaceTypes()) { + TypeParameters result = resolveFromType(superInterface, index); + if (result != null) return result; + } + return null; + } + + private TypeParameters resolveFromType(Type type, IndexView index) { + if (type.kind() == Type.Kind.PARAMETERIZED_TYPE) { + ParameterizedType pt = type.asParameterizedType(); + DotName name = pt.name(); + if (name.equals(BASIC_REPOSITORY) || name.equals(CRUD_REPOSITORY) + || name.equals(DATA_REPOSITORY) || name.equals(MORPHIUM_REPOSITORY)) { + if (pt.arguments().size() >= 2) { + DotName entityType = pt.arguments().get(0).name(); + DotName idType = pt.arguments().get(1).name(); + return new TypeParameters(entityType, idType); + } + } + ClassInfo ci = index.getClassByName(name); + if (ci != null) { + for (Type si : ci.interfaceTypes()) { + TypeParameters result = resolveFromType( + resolveTypeArgs(si, ci.typeParameters(), pt.arguments()), index); + if (result != null) return result; + } + } + } else if (type.kind() == Type.Kind.CLASS) { + ClassInfo ci = index.getClassByName(type.name()); + if (ci != null) { + for (Type si : ci.interfaceTypes()) { + TypeParameters result = resolveFromType(si, index); + if (result != null) return result; + } + } + } + return null; + } + + private Type resolveTypeArgs(Type type, List typeParams, List actualArgs) { + if (type.kind() == Type.Kind.TYPE_VARIABLE) { + String varName = type.asTypeVariable().identifier(); + for (int i = 0; i < typeParams.size(); i++) { + if (typeParams.get(i).identifier().equals(varName) && i < actualArgs.size()) { + return actualArgs.get(i); + } + } + } else if (type.kind() == Type.Kind.PARAMETERIZED_TYPE) { + ParameterizedType pt = type.asParameterizedType(); + List resolvedArgs = new ArrayList<>(); + boolean changed = false; + for (Type arg : pt.arguments()) { + Type resolved = resolveTypeArgs(arg, typeParams, actualArgs); + resolvedArgs.add(resolved); + if (resolved != arg) changed = true; + } + if (changed) { + return ParameterizedType.create(pt.name(), resolvedArgs.toArray(new Type[0]), null); + } + } + return type; + } + + private String findIdField(ClassInfo entityClass, IndexView index) { + ClassInfo current = entityClass; + while (current != null) { + for (FieldInfo field : current.fields()) { + if (field.hasAnnotation(ID_ANNOTATION)) { + return field.name(); + } + } + DotName superName = current.superName(); + if (superName == null || superName.toString().equals("java.lang.Object")) break; + current = index.getClassByName(superName); + } + return null; + } + + private boolean implementsInterface(ClassInfo classInfo, DotName interfaceName, IndexView index) { + if (classInfo == null) return false; + for (Type si : classInfo.interfaceTypes()) { + DotName name = si.name(); + if (name.equals(interfaceName)) return true; + ClassInfo siClass = index.getClassByName(name); + if (siClass != null && implementsInterface(siClass, interfaceName, index)) return true; + } + return false; + } + + private Set collectEntityFields(ClassInfo entityClass, IndexView index) { + Set fields = new LinkedHashSet<>(); + ClassInfo current = entityClass; + while (current != null) { + for (FieldInfo field : current.fields()) { + if (!Modifier.isStatic(field.flags()) + && !Modifier.isTransient(field.flags())) { + fields.add(field.name()); + } + } + DotName superName = current.superName(); + if (superName == null || superName.toString().equals("java.lang.Object")) break; + current = index.getClassByName(superName); + } + return fields; + } + + // -- Return type analysis -- + + /** + * True if {@code paramType} is the Jakarta Data entity-lifecycle shape for the given + * entity: the entity class itself, an array of it, or a List/Collection/Iterable + * parameterized with it (jakarta.data-api 1.0.1, {@code @Delete} javadoc). Used to keep + * such parameters out of the @By-condition parameter-name fallback in + * {@link #generateDeleteAnnotatedMethod}. + */ + private boolean isEntityParameter(Type paramType, String entityClassName) { + if (paramType == null) return false; + if (paramType.kind() == Type.Kind.ARRAY) { + Type component = paramType.asArrayType().component(); + return component.name().toString().equals(entityClassName); + } + if (paramType.kind() == Type.Kind.PARAMETERIZED_TYPE) { + String rawName = paramType.name().toString(); + if (rawName.equals("java.util.List") + || rawName.equals("java.util.Collection") + || rawName.equals("java.lang.Iterable")) { + List args = paramType.asParameterizedType().arguments(); + return !args.isEmpty() && args.get(0).name().toString().equals(entityClassName); + } + return false; + } + return paramType.name().toString().equals(entityClassName); + } + + private boolean isList(Type type) { + return type.name().toString().equals("java.util.List"); + } + + private boolean isStream(Type type) { + return type.name().toString().equals("java.util.stream.Stream"); + } + + private boolean isOptional(Type type) { + return type.name().toString().equals("java.util.Optional"); + } + + private boolean isCompletionStage(Type type) { + return type.name().equals(COMPLETION_STAGE_TYPE); + } + + /** + * If the type is {@code CompletionStage}, returns the inner type X. + * Otherwise returns null. + */ + private Type unwrapCompletionStage(Type type) { + if (!isCompletionStage(type)) return null; + if (type.kind() == Type.Kind.PARAMETERIZED_TYPE) { + return type.asParameterizedType().arguments().get(0); + } + return null; + } + + // -- Generic signature builder -- + + private String buildGenericSignature(String superClass, String interfaceName, + String entityClass, String idClass) { + String entityDesc = "L" + entityClass.replace('.', '/') + ";"; + String idDesc = "L" + idClass.replace('.', '/') + ";"; + String superDesc = "L" + superClass.replace('.', '/') + "<" + entityDesc + idDesc + ">;"; + String ifaceDesc = "L" + interfaceName.replace('.', '/') + ";"; + return superDesc + ifaceDesc; + } +} diff --git a/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumDevServicesBuildTimeConfig.java b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumDevServicesBuildTimeConfig.java new file mode 100644 index 000000000..9c877ba46 --- /dev/null +++ b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumDevServicesBuildTimeConfig.java @@ -0,0 +1,73 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.deployment; + +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +/** + * Build-time configuration for Morphium Dev Services. + * + *

    Dev Services automatically start a MongoDB container in dev and test mode + * when no explicit {@code quarkus.morphium.hosts} is configured. + * + *

    Example – disable Dev Services (use an external MongoDB instead): + *

    {@code
    + * quarkus.morphium.devservices.enabled=false
    + * quarkus.morphium.hosts=my-mongo:27017
    + * quarkus.morphium.database=mydb
    + * }
    + */ +@ConfigMapping(prefix = "quarkus.morphium.devservices") +@ConfigRoot(phase = ConfigPhase.BUILD_TIME) +public interface MorphiumDevServicesBuildTimeConfig { + + /** + * Whether Dev Services are enabled. + * Set to {@code false} to use an external MongoDB and suppress container startup. + */ + @WithDefault("true") + boolean enabled(); + + /** + * Docker image name for the MongoDB container. + * Defaults to {@code mongo:8} (latest MongoDB 8.x). + */ + @WithDefault("mongo:8") + String imageName(); + + /** + * Database name injected as {@code quarkus.morphium.database} when Dev Services start. + * Override in {@code application.properties} if a different name is needed. + */ + @WithDefault("morphium-dev") + String databaseName(); + + /** + * Whether to start MongoDB as a single-node replica set instead of a standalone instance. + * + *

    Defaults to {@code true} so that multi-document transactions, change streams, + * and other oplog-dependent features work out of the box. The extension achieves this + * by calling Testcontainers' {@code MongoDBContainer.withReplicaSet()}. + * + *

    Set to {@code false} only if you explicitly need a standalone MongoDB (e.g. with + * an older image like {@code mongo:6}). + */ + @WithDefault("true") + boolean replicaSet(); +} diff --git a/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumDevServicesProcessor.java b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumDevServicesProcessor.java new file mode 100644 index 000000000..ac680174e --- /dev/null +++ b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumDevServicesProcessor.java @@ -0,0 +1,167 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.deployment; + +import io.quarkus.deployment.IsDevServicesSupportedByLaunchMode; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.builditem.CuratedApplicationShutdownBuildItem; +import io.quarkus.deployment.builditem.DevServicesResultBuildItem; +import io.quarkus.deployment.builditem.DockerStatusBuildItem; +import io.quarkus.runtime.configuration.ConfigUtils; +import org.jboss.logging.Logger; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Quarkus build-time processor that automatically starts a MongoDB container + * in dev and test mode when no explicit {@code quarkus.morphium.hosts} is configured. + * + *

    Uses static volatile fields for container reuse across augmentation phases + * (e.g. different {@code @QuarkusTestProfile} switches). This is the same pattern + * used by Quarkus's own MongoDB extension ({@code DevServicesMongoProcessor}). + * + *

    The Quarkus {@code owned()} Dev Services API has a container reuse defect: + * {@code ComparableDevServicesConfig} overrides {@code equals()} with + * {@code reflectiveEquals()} for cross-classloader comparison but does NOT override + * {@code hashCode()}. The auto-generated record {@code hashCode()} calls + * {@code .hashCode()} on the {@code globalConfig} proxy, which is identity-based + * and differs across augmentation phases. This causes {@code ConcurrentHashMap.get()} + * to miss the existing entry, creating a new container each augmentation. + * + *

    Dev Services are skipped when: + *

      + *
    • {@code quarkus.morphium.devservices.enabled=false}
    • + *
    • {@code quarkus.morphium.hosts} is explicitly set
    • + *
    • The application runs in normal (production) mode
    • + *
    + */ +public class MorphiumDevServicesProcessor { + + private static final Logger log = Logger.getLogger(MorphiumDevServicesProcessor.class); + + // Static fields survive across augmentation phases because the deployment + // processor class is loaded once and not replaced during re-augmentation. + static volatile MongoDBStartable runningContainer; + static volatile CapturedConfig capturedConfig; + static volatile boolean first = true; + + @BuildStep(onlyIf = IsDevServicesSupportedByLaunchMode.class) + public DevServicesResultBuildItem startDevServices( + MorphiumDevServicesBuildTimeConfig config, + DockerStatusBuildItem dockerStatusBuildItem, + CuratedApplicationShutdownBuildItem closeBuildItem) { + + if (!config.enabled()) { + log.debug("Morphium Dev Services disabled via quarkus.morphium.devservices.enabled=false"); + return null; + } + + if (!dockerStatusBuildItem.isDockerAvailable()) { + // Same guard Quarkus's own DevServicesMongoProcessor uses. Without it, a container + // start attempt on a machine with no Docker daemon throws mid-augmentation and + // fails the whole build instead of just skipping Dev Services -- the exact + // scenario the module's own MorphiumTransactionalTest works around at the test + // level (a @BeforeAll Docker check), but which has no equivalent guard here at + // the point the container would actually be started. + log.warn("Docker isn't working, please configure quarkus.morphium.hosts or " + + "quarkus.morphium.atlas-url — Morphium Dev Services will not start a MongoDB container"); + return null; + } + + if (ConfigUtils.isPropertyNonEmpty("quarkus.morphium.hosts") + || ConfigUtils.isPropertyNonEmpty("quarkus.morphium.atlas-url")) { + log.debug("Morphium connection settings already configured – skipping Dev Services"); + return null; + } + + if (ConfigUtils.getFirstOptionalValue(List.of("quarkus.morphium.driver-name"), String.class) + .map(driverName -> driverName.equalsIgnoreCase("InMemDriver")) + .orElse(false)) { + log.debugf("Morphium driver-name explicitly set to InMemDriver – " + + "skipping Dev Services since no real MongoDB connection is needed"); + return null; + } + + CapturedConfig currentConfig = new CapturedConfig(config.imageName(), config.replicaSet(), config.databaseName()); + + // Reuse existing container if config hasn't changed + if (runningContainer != null) { + if (currentConfig.equals(capturedConfig)) { + log.debug("Reusing existing MongoDB Dev Services container"); + return buildResult(runningContainer, currentConfig); + } + // Config changed — close old container and start fresh + log.info("Morphium Dev Services config changed — restarting container"); + closeContainer(); + } + + log.infof("Morphium Dev Services: starting MongoDB %s from image '%s'", + config.replicaSet() ? "replica set" : "standalone", config.imageName()); + + MongoDBStartable startable = new MongoDBStartable(config.imageName(), config.replicaSet()); + startable.start(); + + runningContainer = startable; + capturedConfig = currentConfig; + + // Register shutdown hook (only once per JVM lifecycle) + if (first) { + first = false; + closeBuildItem.addCloseTask(() -> { + closeContainer(); + first = true; + }, true); + } + + log.infof("MongoDB Dev Services ready at %s:%d", startable.getHost(), startable.getMappedPort()); + + return buildResult(startable, currentConfig); + } + + private static DevServicesResultBuildItem buildResult(MongoDBStartable startable, CapturedConfig config) { + Map configMap = new HashMap<>(); + configMap.put("quarkus.morphium.hosts", startable.getHost() + ":" + startable.getMappedPort()); + configMap.put("quarkus.morphium.database", config.databaseName()); + if (config.replicaSet()) { + String rsName = startable.getReplicaSetName(); + configMap.put("quarkus.morphium.replica-set-name", rsName != null ? rsName : "docker-rs"); + } + + return DevServicesResultBuildItem.discovered() + .feature("morphium") + .containerId(startable.getContainerId()) + .config(configMap) + .description("MongoDB (" + config.imageName() + ")") + .build(); + } + + private static void closeContainer() { + if (runningContainer != null) { + try { + runningContainer.close(); + } catch (Exception e) { + log.warn("Failed to close MongoDB Dev Services container", e); + } + runningContainer = null; + capturedConfig = null; + } + } + + record CapturedConfig(String imageName, boolean replicaSet, String databaseName) { + } +} diff --git a/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumDevUIProcessor.java b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumDevUIProcessor.java new file mode 100644 index 000000000..7d7c93ae2 --- /dev/null +++ b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumDevUIProcessor.java @@ -0,0 +1,61 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.deployment; + +import de.caluga.morphium.quarkus.MorphiumDevUIJsonRpcService; +import io.quarkus.deployment.IsDevelopment; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.devui.spi.JsonRPCProvidersBuildItem; +import io.quarkus.devui.spi.page.CardPageBuildItem; +import io.quarkus.devui.spi.page.Page; + +/** + * Registers the Morphium extension in the Quarkus Dev UI. + * + *

    Uses a runtime {@link MorphiumDevUIJsonRpcService} to display the actual + * MongoDB connection state (including auto-detected replica set mode) in the + * Dev UI at {@code /q/dev-ui/}. + */ +public class MorphiumDevUIProcessor { + + @BuildStep(onlyIf = IsDevelopment.class) + JsonRPCProvidersBuildItem registerJsonRpcService() { + return new JsonRPCProvidersBuildItem(MorphiumDevUIJsonRpcService.class); + } + + @BuildStep(onlyIf = IsDevelopment.class) + void createCard(BuildProducer cardProducer) { + + CardPageBuildItem card = new CardPageBuildItem(); + + // --- Library version labels (shown at card footer, like Kafka/ArC) --- + card.addLibraryVersion("de.caluga", "morphium", + "Morphium", "https://github.com/sboesebeck/morphium"); + card.addLibraryVersion("de.caluga", "quarkus-morphium", + "Quarkus Morphium Extension", "https://github.com/sboesebeck/morphium"); + card.addLibraryVersion("jakarta.data", "jakarta.data-api", + "Jakarta Data", "https://jakarta.ee/specifications/data/"); + + // --- MongoDB Connection page (runtime data via JsonRPC) --- + card.addPage(Page.webComponentPageBuilder() + .title("MongoDB Connection") + .icon("font-awesome-solid:database") + .componentLink("qwc-morphium-connection.js")); + + cardProducer.produce(card); + } +} diff --git a/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumEntitiesRegisteredBuildItem.java b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumEntitiesRegisteredBuildItem.java new file mode 100644 index 000000000..a94a3357a --- /dev/null +++ b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumEntitiesRegisteredBuildItem.java @@ -0,0 +1,15 @@ +package de.caluga.morphium.quarkus.deployment; + +import io.quarkus.builder.item.SimpleBuildItem; + +/** + * Marker build item indicating that {@code @Entity}/{@code @Embedded} class names + * have been passed to the {@link de.caluga.morphium.quarkus.MorphiumRecorder} via + * {@code setMappedClassNames()}. + * + *

    Other build steps that depend on the entity list being available at runtime + * (e.g. migration execution) must consume this build item to guarantee correct + * ordering of {@code @Record(RUNTIME_INIT)} bytecode blocks. + */ +public final class MorphiumEntitiesRegisteredBuildItem extends SimpleBuildItem { +} diff --git a/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumFeature.java b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumFeature.java new file mode 100644 index 000000000..12b4a65cc --- /dev/null +++ b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumFeature.java @@ -0,0 +1,26 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.deployment; + +import io.quarkus.builder.item.SimpleBuildItem; + +/** + * Marker build item that indicates the Morphium extension is active. + * Used by {@link MorphiumProcessor} to signal feature registration. + */ +public final class MorphiumFeature extends SimpleBuildItem { + static final String FEATURE_NAME = "morphium"; +} diff --git a/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumHealthBuildTimeConfig.java b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumHealthBuildTimeConfig.java new file mode 100644 index 000000000..998dd0629 --- /dev/null +++ b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumHealthBuildTimeConfig.java @@ -0,0 +1,42 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.deployment; + +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +/** + * Build-time configuration for Morphium health checks. + * + *

    When enabled (the default), liveness, readiness and startup health checks + * are registered with the SmallRye Health subsystem. Set to {@code false} to + * suppress all Morphium health checks: + *

    {@code
    + * quarkus.morphium.health.enabled=false
    + * }
    + */ +@ConfigMapping(prefix = "quarkus.morphium.health") +@ConfigRoot(phase = ConfigPhase.BUILD_TIME) +public interface MorphiumHealthBuildTimeConfig { + + /** + * Whether Morphium health checks (liveness, readiness, startup) are enabled. + */ + @WithDefault("true") + boolean enabled(); +} diff --git a/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumMigrationProcessor.java b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumMigrationProcessor.java new file mode 100644 index 000000000..5b37dc3ee --- /dev/null +++ b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumMigrationProcessor.java @@ -0,0 +1,104 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.deployment; + +import de.caluga.morphium.quarkus.MorphiumRecorder; +import de.caluga.morphium.quarkus.migration.MorphiumChangeUnit; +import io.quarkus.arc.deployment.SyntheticBeansRuntimeInitBuildItem; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.Consume; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.CombinedIndexBuildItem; +import io.quarkus.deployment.builditem.ServiceStartBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTarget; +import org.jboss.jandex.DotName; +import org.jboss.jandex.IndexView; +import org.jboss.logging.Logger; + +import java.util.ArrayList; +import java.util.List; + +/** + * Build-time processor for the Morphium migration framework. + * + *

    Scans the Jandex index for {@link MorphiumChangeUnit} annotated classes, + * registers them for GraalVM reflection, passes them to the {@link MorphiumRecorder}, + * and triggers migration execution at runtime. + */ +public class MorphiumMigrationProcessor { + + private static final Logger log = Logger.getLogger(MorphiumMigrationProcessor.class); + private static final DotName CHANGE_UNIT = DotName.createSimple(MorphiumChangeUnit.class.getName()); + + /** + * Discovers all {@code @MorphiumChangeUnit} classes at build time and passes + * their names to the recorder for runtime execution. + */ + @BuildStep + @Record(ExecutionTime.STATIC_INIT) + void discoverMigrations(CombinedIndexBuildItem combinedIndex, + BuildProducer reflectiveClasses, + MorphiumRecorder recorder) { + + IndexView index = combinedIndex.getIndex(); + List migrationClassNames = new ArrayList<>(); + + for (AnnotationInstance ai : index.getAnnotations(CHANGE_UNIT)) { + if (ai.target().kind() != AnnotationTarget.Kind.CLASS) { + continue; + } + String className = ai.target().asClass().name().toString(); + migrationClassNames.add(className); + + // Register for GraalVM native image reflection + reflectiveClasses.produce(ReflectiveClassBuildItem.builder(className) + .constructors(true) + .methods(true) + .fields(true) + .build()); + + log.debugf("Morphium Migration: discovered @MorphiumChangeUnit %s", className); + } + + if (!migrationClassNames.isEmpty()) { + log.infof("Morphium Migration: discovered %d @MorphiumChangeUnit class(es)", migrationClassNames.size()); + } + + recorder.setMigrationClassNames(migrationClassNames); + } + + /** + * Executes pending migrations at RUNTIME_INIT after the Morphium bean is available. + * Consumes {@link MorphiumEntitiesRegisteredBuildItem} to guarantee that + * {@code setMappedClassNames()} has been replayed before this step runs — + * otherwise the Morphium bean creation triggered here would see an empty + * entity list and skip index creation. + * Produces a {@link ServiceStartBuildItem} to ensure migrations complete before + * the application starts serving requests. + */ + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + @Consume(SyntheticBeansRuntimeInitBuildItem.class) + ServiceStartBuildItem executeMigrations(MorphiumRecorder recorder, + MorphiumEntitiesRegisteredBuildItem entitiesRegistered) { + recorder.runMigrations(); + return new ServiceStartBuildItem("morphium-migration"); + } +} diff --git a/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessor.java b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessor.java new file mode 100644 index 000000000..842a749f8 --- /dev/null +++ b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessor.java @@ -0,0 +1,585 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.deployment; + +import de.caluga.morphium.annotations.Capped; +import de.caluga.morphium.annotations.Driver; +import de.caluga.morphium.annotations.Embedded; +import de.caluga.morphium.annotations.Entity; +import de.caluga.morphium.annotations.Messaging; +import de.caluga.morphium.quarkus.MorphiumRecorder; +import de.caluga.morphium.quarkus.migration.MorphiumMigrationEntry; +import de.caluga.morphium.quarkus.migration.MorphiumMigrationLock; +import de.caluga.morphium.DefaultNameProvider; +import de.caluga.morphium.encryption.DefaultEncryptionKeyProvider; +import de.caluga.morphium.encryption.AESEncryptionProvider; +import de.caluga.morphium.IndexDescription; +import io.quarkus.arc.deployment.AdditionalBeanBuildItem; +import io.quarkus.deployment.Capabilities; +import io.quarkus.deployment.Capability; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.annotations.BuildStep; +import io.quarkus.deployment.annotations.ExecutionTime; +import io.quarkus.deployment.annotations.Record; +import io.quarkus.deployment.builditem.CombinedIndexBuildItem; +import io.quarkus.deployment.builditem.FeatureBuildItem; +import io.quarkus.deployment.builditem.IndexDependencyBuildItem; +import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; +import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedClassBuildItem; +import io.quarkus.deployment.builditem.nativeimage.RuntimeInitializedPackageBuildItem; +import io.quarkus.smallrye.health.deployment.spi.HealthBuildItem; +import de.caluga.morphium.quarkus.MorphiumProducer; +import de.caluga.morphium.quarkus.transaction.MorphiumTransactionalInterceptor; +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.AnnotationTarget; +import org.jboss.jandex.AnnotationValue; +import org.jboss.jandex.DotName; +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.IndexView; +import org.jboss.logging.Logger; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Quarkus build-time processor for the Morphium extension. + * + *

    Responsibilities: + *

      + *
    1. Register the {@code "morphium"} feature so it appears in the Quarkus banner.
    2. + *
    3. Make the CDI producer bean available to the application.
    4. + *
    5. Register all classes annotated with {@link Entity} or {@link Embedded} + * for GraalVM reflection so that Morphium's ObjectMapper can serialise and + * deserialise them in a native image without requiring {@code reflect-config.json}.
    6. + *
    + * + *

    This class uses only standard Quarkus build-item APIs and Jandex for + * annotation scanning – no {@code sun.*} imports, no {@code Unsafe} access. + * + *

    Note: Jandex only discovers {@code @Entity}/{@code @Embedded} classes in + * the application and in dependencies that provide a Jandex index. For entities in + * external (unindexed) JARs, add {@code quarkus.index-dependency} entries in + * {@code application.properties} or use the {@code jandex-maven-plugin}. + */ +public class MorphiumProcessor { + + private static final Logger log = Logger.getLogger(MorphiumProcessor.class); + + // ------------------------------------------------------------------ + // Feature registration + // ------------------------------------------------------------------ + + @BuildStep + FeatureBuildItem feature() { + return new FeatureBuildItem(MorphiumFeature.FEATURE_NAME); + } + + // ------------------------------------------------------------------ + // Jandex index for morphium-core (ships no jandex.idx in its JAR) + // ------------------------------------------------------------------ + + /** + * Instructs Quarkus to index the morphium-core JAR so that Jandex + * picks up {@code @Driver}, {@code @Messaging}, {@code @Entity}, + * {@code @Embedded}, and {@code @Capped} classes from morphium-core + * itself. Without this, {@code CombinedIndexBuildItem} only contains + * application classes, and the driver/messaging discovery silently + * returns empty lists. + */ + @BuildStep + IndexDependencyBuildItem indexMorphiumCore() { + return new IndexDependencyBuildItem("de.caluga", "morphium"); + } + + // ------------------------------------------------------------------ + // CDI bean registration + // ------------------------------------------------------------------ + + @BuildStep + AdditionalBeanBuildItem registerBeans() { + // Register runtime CDI beans required by the extension. + // MorphiumRuntimeConfig / CacheConfig are @ConfigMapping interfaces and are + // registered automatically by the SmallRye Config Quarkus extension. + // MorphiumRecorder is a @Recorder (build-time only) and must not appear here. + // MorphiumBlockingCallDetector is no longer a CDI bean: it is a plain static + // utility invoked directly by MorphiumProducer.buildMorphium() right after the + // real connect, so it must not be registered here. + return AdditionalBeanBuildItem.builder() + .addBeanClasses( + MorphiumProducer.class, + MorphiumTransactionalInterceptor.class) + .setUnremovable() + .build(); + } + + // ------------------------------------------------------------------ + // JSON serialization: MorphiumId <-> hex string + // ------------------------------------------------------------------ + + /** + * Registers the {@code MorphiumId} JSON customizers, but only for the JSON + * layer(s) actually present on the application classpath. + * + *

    Without these, Jackson/JSON-B walk {@code MorphiumId}'s getters and emit + * the internal {@code {pid, counter, machineId, bytes, time}} struct, which is + * unusable as a row id on the consumer side (a frontend grid keying rows by id + * gets {@code "[object Object]"} for every row). The customizers serialize + * {@code MorphiumId} as its canonical 24-char hex string and parse it back. + * + *

    Gating on {@link Capabilities} keeps {@code quarkus-jackson} / + * {@code quarkus-jsonb} optional: the customizer beans are added only when the + * matching capability is registered, so an app that pulls in neither JSON layer + * never references the (absent) customizer classes. + */ + @BuildStep + void registerMorphiumIdJsonCustomizers(Capabilities capabilities, + BuildProducer additionalBeans) { + if (capabilities.isPresent(Capability.JACKSON)) { + additionalBeans.produce(AdditionalBeanBuildItem.builder() + .addBeanClass("de.caluga.morphium.quarkus.json.MorphiumIdJacksonModule") + .setUnremovable() + .build()); + } + if (capabilities.isPresent(Capability.JSONB)) { + additionalBeans.produce(AdditionalBeanBuildItem.builder() + .addBeanClass("de.caluga.morphium.quarkus.json.MorphiumIdJsonbModule") + .setUnremovable() + .build()); + } + } + + // ------------------------------------------------------------------ + // Health check registration + // ------------------------------------------------------------------ + + @BuildStep + HealthBuildItem addLivenessCheck(MorphiumHealthBuildTimeConfig config) { + return new HealthBuildItem( + "de.caluga.morphium.quarkus.health.MorphiumLivenessCheck", + config.enabled()); + } + + @BuildStep + HealthBuildItem addReadinessCheck(MorphiumHealthBuildTimeConfig config) { + return new HealthBuildItem( + "de.caluga.morphium.quarkus.health.MorphiumReadinessCheck", + config.enabled()); + } + + @BuildStep + HealthBuildItem addStartupCheck(MorphiumHealthBuildTimeConfig config) { + return new HealthBuildItem( + "de.caluga.morphium.quarkus.health.MorphiumStartupCheck", + config.enabled()); + } + + // ------------------------------------------------------------------ + // GraalVM native image: reflection registration for @Entity / @Embedded + // ------------------------------------------------------------------ + + /** + * Registers Morphium entity class names for GraalVM reflection and stores them in the + * {@link MorphiumRecorder} for later use during runtime initialization. + * + *

    Why {@code RUNTIME_INIT}: This step writes into a {@code static volatile} + * field in {@code MorphiumRecorder}. It must complete before all + * subsequent {@code RUNTIME_INIT} steps — in particular before + * {@link MorphiumMigrationProcessor#executeMigrations}, which triggers the first CDI lookup + * of {@code Morphium} and therefore calls {@code ensureIndicesFor()} on the entity list. + * An earlier draft used {@code STATIC_INIT} here, but that caused a race condition: the + * entity list could still be empty when {@code Morphium} was first instantiated, resulting + * in missing unique indexes and silent {@code saveDuplicate} test failures. + */ + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + MorphiumEntitiesRegisteredBuildItem registerEntitiesForReflection(BuildProducer reflectiveClasses, + CombinedIndexBuildItem combinedIndex, + MorphiumRecorder recorder) { + // Collect @Entity and @Embedded class names separately for ClassGraphCache + // pre-registration, plus a combined set for typeId/index registration. + Set allClassNames = new LinkedHashSet<>(); + List entityClassNames = new ArrayList<>(); + List embeddedClassNames = new ArrayList<>(); + IndexView index = combinedIndex.getIndex(); + + DotName entityDotName = DotName.createSimple(Entity.class.getName()); + DotName embeddedDotName = DotName.createSimple(Embedded.class.getName()); + + // Track already-registered superclasses to avoid duplicates + Set registeredSuperclasses = new LinkedHashSet<>(); + + for (AnnotationInstance ai : index.getAnnotations(entityDotName)) { + if (ai.target().kind() == AnnotationTarget.Kind.CLASS) { + String className = ai.target().asClass().name().toString(); + registerClass(className, reflectiveClasses); + registerSuperclasses(ai.target().asClass(), index, reflectiveClasses, registeredSuperclasses); + registerSubclasses(ai.target().asClass(), index, reflectiveClasses, registeredSuperclasses); + entityClassNames.add(className); + allClassNames.add(className); + registerCustomNameProvider(ai, reflectiveClasses); + } + } + for (AnnotationInstance ai : index.getAnnotations(embeddedDotName)) { + if (ai.target().kind() == AnnotationTarget.Kind.CLASS) { + String className = ai.target().asClass().name().toString(); + registerClass(className, reflectiveClasses); + registerSuperclasses(ai.target().asClass(), index, reflectiveClasses, registeredSuperclasses); + registerSubclasses(ai.target().asClass(), index, reflectiveClasses, registeredSuperclasses); + embeddedClassNames.add(className); + // @Embedded classes need pre-registration for typeId mapping + allClassNames.add(className); + } + } + + // Extension-internal @Entity classes are not in the app Jandex index — register for + // reflection only. They are NOT added to mappedClassNames because their collections may + // be renamed via configuration, and ensureIndicesFor() would create indexes on the + // annotation-defined names instead of the configured ones. + registerClass(MorphiumMigrationEntry.class.getName(), reflectiveClasses); + registerClass(MorphiumMigrationLock.class.getName(), reflectiveClasses); + + // Morphium-internal classes reflectively instantiated via getDeclaredConstructor().newInstance(): + // - DefaultNameProvider: ObjectMapperImpl.getNameProviderForClass(), default @Entity(nameProvider=...) + // - DefaultEncryptionKeyProvider: Morphium.initializeAndConnect(), default encryption key provider + // - AESEncryptionProvider: Morphium.initializeAndConnect(), default value encryption provider + registerClass(DefaultNameProvider.class.getName(), reflectiveClasses); + registerClass(DefaultEncryptionKeyProvider.class.getName(), reflectiveClasses); + registerClass(AESEncryptionProvider.class.getName(), reflectiveClasses); + + // IndexDescription: uses AnnotationAndReflectionHelper.getField() and getAllFields() for + // reflective field access in fromMap() and asMap(). Without registration, getDeclaredFields() + // returns no fields in native mode, so IndexDescription.key is never populated → NPE in createIndex(). + registerClass(IndexDescription.class.getName(), reflectiveClasses); + + // HelloResult: fromMsg() and toMsg() use getAllFields(HelloResult.class) to parse the MongoDB + // hello/isMaster response via reflection. Without registration, critical fields like setName, + // isWritablePrimary, hosts are silently null → driver cannot detect replica sets. + registerClass("de.caluga.morphium.driver.wire.HelloResult", reflectiveClasses); + + // Wire protocol message classes — reflectively instantiated via + // WireProtocolMessage.OpCode.handler.getDeclaredConstructor().newInstance() when + // parsing MongoDB server responses. All 9 OpCode handler classes need registration. + String[] wireProtocolClasses = { + "de.caluga.morphium.driver.wireprotocol.OpReply", + "de.caluga.morphium.driver.wireprotocol.OpUpdate", + "de.caluga.morphium.driver.wireprotocol.OpInsert", + "de.caluga.morphium.driver.wireprotocol.OpQuery", + "de.caluga.morphium.driver.wireprotocol.OpGetMore", + "de.caluga.morphium.driver.wireprotocol.OpDelete", + "de.caluga.morphium.driver.wireprotocol.OpKillCursors", + "de.caluga.morphium.driver.wireprotocol.OpCompressed", + "de.caluga.morphium.driver.wireprotocol.OpMsg" + }; + for (String cls : wireProtocolClasses) { + registerClass(cls, reflectiveClasses); + } + + // Pass discovered @Entity/@Embedded classes to runtime for registerTypeIds() pre-registration. + // This combined list is used ONLY for typeId mapping (buildTypeIdMap) — NOT for index creation. + // ensureIndices() must use getEntityClassNames() because ensureIndicesFor() calls + // getCollectionName() which throws IllegalArgumentException for @Embedded-only classes. + // Always call setMappedClassNames (even when empty) to reset state on hot reload. + if (!allClassNames.isEmpty()) { + log.infof("Morphium: passing %d @Entity/@Embedded classes for runtime pre-registration", allClassNames.size()); + } + recorder.setMappedClassNames(new ArrayList<>(allClassNames)); + + // Pass @Entity and @Embedded lists separately for ClassGraphCache pre-population. + // In native mode, ObjectMapperImpl calls getClassesWithAnnotation(Entity.class.getName()) + // which must find the pre-populated cache entry to avoid a live ClassGraph scan. + recorder.setEntityClassNames(entityClassNames); + recorder.setEmbeddedClassNames(embeddedClassNames); + return new MorphiumEntitiesRegisteredBuildItem(); + } + + // ------------------------------------------------------------------ + // GraalVM native image: @Driver class discovery and pre-population + // ------------------------------------------------------------------ + + /** + * Discovers all {@code @Driver}-annotated classes at build time via Jandex, + * registers them for GraalVM reflection, and passes the list to the recorder + * so that {@link MorphiumProducer} can pre-populate {@code ClassGraphCache} + * before {@code Morphium} is constructed. This bypasses the ClassGraph + * classpath scan that fails in native mode. + */ + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + void registerDriversForNative(BuildProducer reflectiveClasses, + CombinedIndexBuildItem combinedIndex, + MorphiumRecorder recorder) { + IndexView index = combinedIndex.getIndex(); + DotName driverDotName = DotName.createSimple(Driver.class.getName()); + List driverNames = new ArrayList<>(); + + for (AnnotationInstance ai : index.getAnnotations(driverDotName)) { + if (ai.target().kind() == AnnotationTarget.Kind.CLASS) { + String className = ai.target().asClass().name().toString(); + registerClass(className, reflectiveClasses); + driverNames.add(className); + } + } + + if (!driverNames.isEmpty()) { + log.infof("Morphium: passing %d @Driver classes for native-image ClassGraphCache pre-population", driverNames.size()); + } + recorder.setDriverClassNames(driverNames); + } + + // ------------------------------------------------------------------ + // GraalVM native image: @Messaging class discovery and pre-population + // ------------------------------------------------------------------ + + /** + * Discovers all {@code @Messaging}-annotated classes at build time via Jandex, + * registers them for GraalVM reflection, and passes the list to the recorder + * so that {@link MorphiumProducer} can pre-populate {@code ClassGraphCache} + * before {@code Morphium} is constructed. This bypasses the ClassGraph + * classpath scan that fails in native mode. + */ + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + void registerMessagingForNative(BuildProducer reflectiveClasses, + CombinedIndexBuildItem combinedIndex, + MorphiumRecorder recorder) { + IndexView index = combinedIndex.getIndex(); + DotName messagingDotName = DotName.createSimple(Messaging.class.getName()); + List messagingNames = new ArrayList<>(); + + for (AnnotationInstance ai : index.getAnnotations(messagingDotName)) { + if (ai.target().kind() == AnnotationTarget.Kind.CLASS) { + String className = ai.target().asClass().name().toString(); + registerClass(className, reflectiveClasses); + messagingNames.add(className); + } + } + + if (!messagingNames.isEmpty()) { + log.infof("Morphium: passing %d @Messaging classes for native-image ClassGraphCache pre-population", messagingNames.size()); + } + recorder.setMessagingClassNames(messagingNames); + } + + // ------------------------------------------------------------------ + // GraalVM native image: @Capped class discovery and pre-population + // ------------------------------------------------------------------ + + /** + * Discovers all {@code @Capped}-annotated classes at build time via Jandex + * and passes the list (possibly empty) to the recorder so that + * {@link MorphiumProducer} can pre-populate {@code ClassGraphCache}. + * + *

    Even when no {@code @Capped} classes exist in the application, + * pre-registering an empty list prevents {@code checkCapped()} from + * triggering a live ClassGraph scan at startup, which crashes in native mode. + */ + @BuildStep + @Record(ExecutionTime.RUNTIME_INIT) + void registerCappedForNative(BuildProducer reflectiveClasses, + CombinedIndexBuildItem combinedIndex, + MorphiumRecorder recorder) { + IndexView index = combinedIndex.getIndex(); + DotName cappedDotName = DotName.createSimple(Capped.class.getName()); + List cappedNames = new ArrayList<>(); + + for (AnnotationInstance ai : index.getAnnotations(cappedDotName)) { + if (ai.target().kind() == AnnotationTarget.Kind.CLASS) { + String className = ai.target().asClass().name().toString(); + registerClass(className, reflectiveClasses); + cappedNames.add(className); + } + } + + if (!cappedNames.isEmpty()) { + log.infof("Morphium: passing %d @Capped classes for native-image ClassGraphCache pre-population", cappedNames.size()); + } + // Always call setCappedClassNames (even with empty list) — pre-registering an empty + // list prevents checkCapped() from falling through to a live ClassGraph scan. + recorder.setCappedClassNames(cappedNames); + } + + + // ------------------------------------------------------------------ + // GraalVM native image: MongoCommand hierarchy reflection registration + // ------------------------------------------------------------------ + + /** + * Registers {@code MongoCommand} and all its subclasses for GraalVM reflection. + * + *

    {@code MongoCommand.asMap()} uses + * {@code AnnotationAndReflectionHelper.getAllFields()} which calls + * {@code Class.getDeclaredFields()} on every class in the hierarchy. In a native + * image, {@code getDeclaredFields()} only returns fields registered for reflection. + * Without this, the {@code $db} field (declared in {@code MongoCommand}) is silently + * missing from the command document, causing MongoDB to reject every OP_MSG with + * "Error: 40571 — OP_MSG requests require a $db argument". + * + *

    Uses Jandex {@code getAllKnownSubclasses()} on the indexed morphium-core JAR + * to discover all concrete and abstract command classes automatically. + */ + @BuildStep + void registerMongoCommandsForReflection(BuildProducer reflectiveClasses, + CombinedIndexBuildItem combinedIndex) { + IndexView index = combinedIndex.getIndex(); + DotName mongoCommandDotName = DotName.createSimple("de.caluga.morphium.driver.commands.MongoCommand"); + + // Register MongoCommand itself (declares $db, coll, comment, $readPreference) + registerClass(mongoCommandDotName.toString(), reflectiveClasses); + + // Register all subclasses (WriteMongoCommand, ReadMongoCommand, AdminMongoCommand, + // and all concrete commands like FindCommand, InsertMongoCommand, etc.) + int count = 1; // counting MongoCommand itself + for (ClassInfo ci : index.getAllKnownSubclasses(mongoCommandDotName)) { + registerClass(ci.name().toString(), reflectiveClasses); + count++; + } + log.infof("Morphium: registered %d MongoCommand classes for reflection (native image)", count); + } + + // ------------------------------------------------------------------ + // GraalVM native image: runtime initialization for Morphium internals + // ------------------------------------------------------------------ + + /** + * Registers Morphium-internal classes that must be initialized at run time + * in GraalVM native images. + * + *

    These classes have static fields (e.g. {@code AnnotationAndReflectionHelper}, + * {@code ScanResult}) that cannot be captured in the image heap because they + * either hold ClassGraph scan results, ZipFile handles, or other runtime-only state. + * + *

    By registering them here, users of quarkus-morphium do not need to add + * {@code --initialize-at-run-time} entries to their {@code application.properties}. + */ + @BuildStep + void registerRuntimeInitializedClasses( + BuildProducer runtimeInitClasses, + BuildProducer runtimeInitPackages) { + + // Morphium core classes with static AnnotationAndReflectionHelper or ClassGraph state + String[] morphiumClasses = { + "de.caluga.morphium.ObjectMapperImpl", + "de.caluga.morphium.AnnotationAndReflectionHelper", + "de.caluga.morphium.ClassGraphCache", + "de.caluga.morphium.driver.commands.MongoCommand", + "de.caluga.morphium.driver.wire.HelloResult", + "de.caluga.morphium.IndexDescription" + }; + + for (String className : morphiumClasses) { + runtimeInitClasses.produce(new RuntimeInitializedClassBuildItem(className)); + } + + // ClassGraph: static fields hold ZipFile/ScanResult objects that cannot be + // serialized into the native image heap + runtimeInitPackages.produce(new RuntimeInitializedPackageBuildItem("io.github.classgraph")); + } + + /** + * Registers all superclasses of a Morphium entity for GraalVM reflection. + * + *

    Morphium's {@code AnnotationAndReflectionHelper.getAllFields()} walks the entire class + * hierarchy via {@code getDeclaredFields()} on each level. If a superclass declares the + * {@code @Id} field (common pattern: {@code BaseEntity} with {@code @Id String id}), that + * field is invisible in a native image unless the superclass is also registered. + * + *

    Stops at {@code java.lang.Object} and skips JDK/library classes. + */ + private void registerSuperclasses(ClassInfo classInfo, IndexView index, + BuildProducer out, + Set alreadyRegistered) { + DotName superName = classInfo.superName(); + while (superName != null && !superName.toString().equals("java.lang.Object")) { + String superClassName = superName.toString(); + if (!alreadyRegistered.add(superClassName)) { + break; // already processed this branch + } + log.debugf("Morphium: registering entity superclass %s for reflection", superClassName); + registerClass(superClassName, out); + + // Walk further up the hierarchy via Jandex (if indexed) or stop + ClassInfo superInfo = index.getClassByName(superName); + if (superInfo == null) { + break; // not in the Jandex index — JDK or non-indexed library class + } + superName = superInfo.superName(); + } + } + + /** + * Registers every subclass (direct and transitive) of a class annotated {@code @Entity} or + * {@code @Embedded}, even though those subclasses carry no annotation of their own. + * + *

    {@code @Entity}/{@code @Embedded} are not {@code @Inherited} (Java annotation + * inheritance does not apply to types), but Morphium's own + * {@code AnnotationAndReflectionHelper.isAnnotationPresentInHierarchy()} walks the class + * hierarchy manually and treats a subclass as an entity/embedded type purely because a + * superclass carries the annotation -- Morphium fully supports polymorphic persistence this + * way. The Jandex scan above only ever finds classes that carry the annotation directly, so + * without this, storing/loading an actual runtime instance of an unannotated subclass would + * reflectively access fields/constructors never registered for native image, and crash only + * in a native build, only for that specific subclass, only once such an instance is + * actually persisted. + */ + void registerSubclasses(ClassInfo classInfo, IndexView index, + BuildProducer out, + Set alreadyRegistered) { + for (ClassInfo subclass : index.getAllKnownSubclasses(classInfo.name())) { + String subclassName = subclass.name().toString(); + if (!alreadyRegistered.add(subclassName)) { + continue; // already processed (e.g. reached via a different entity's hierarchy) + } + log.debugf("Morphium: registering entity subclass %s for reflection", subclassName); + registerClass(subclassName, out); + } + } + + /** + * Registers a custom {@code @Entity(nameProvider = ...)} class for reflection. + * + *

    {@code ObjectMapperImpl.getNameProviderForClass()} instantiates the configured + * {@code nameProvider} via {@code getDeclaredConstructor().newInstance()}, same as + * {@code DefaultNameProvider} (already unconditionally registered above) -- but a custom + * provider a user points {@code @Entity(nameProvider = ...)} at was never registered at + * all, so a native-image build would fail at runtime the first time that entity's + * collection name is resolved. {@code DefaultNameProvider} itself is skipped here since it + * is already registered unconditionally. + */ + void registerCustomNameProvider(AnnotationInstance entityAnnotation, + BuildProducer out) { + AnnotationValue nameProviderValue = entityAnnotation.value("nameProvider"); + if (nameProviderValue == null) { + return; + } + String nameProviderClassName = nameProviderValue.asClass().name().toString(); + if (nameProviderClassName.equals(DefaultNameProvider.class.getName())) { + return; + } + registerClass(nameProviderClassName, out); + } + + private void registerClass(String className, + BuildProducer out) { + log.debugf("Morphium: registering %s for reflection (native image)", className); + out.produce(ReflectiveClassBuildItem.builder(className) + .constructors(true) + .methods(true) + .fields(true) + .build()); + } +} diff --git a/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/RepositoryBuildItem.java b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/RepositoryBuildItem.java new file mode 100644 index 000000000..f4f63e3dd --- /dev/null +++ b/quarkus-morphium/deployment/src/main/java/de/caluga/morphium/quarkus/deployment/RepositoryBuildItem.java @@ -0,0 +1,30 @@ +package de.caluga.morphium.quarkus.deployment; + +import io.quarkus.builder.item.MultiBuildItem; + +/** + * Build item carrying metadata about a discovered {@code @Repository} interface. + * One instance per repository interface, consumed by the code-generation step. + */ +public final class RepositoryBuildItem extends MultiBuildItem { + + private final String interfaceName; + private final String entityClassName; + private final String idClassName; + private final String idFieldName; + + public RepositoryBuildItem(String interfaceName, + String entityClassName, + String idClassName, + String idFieldName) { + this.interfaceName = interfaceName; + this.entityClassName = entityClassName; + this.idClassName = idClassName; + this.idFieldName = idFieldName; + } + + public String getInterfaceName() { return interfaceName; } + public String getEntityClassName() { return entityClassName; } + public String getIdClassName() { return idClassName; } + public String getIdFieldName() { return idFieldName; } +} diff --git a/quarkus-morphium/deployment/src/main/resources/META-INF/quarkus-build-steps.list b/quarkus-morphium/deployment/src/main/resources/META-INF/quarkus-build-steps.list new file mode 100644 index 000000000..cb79cf795 --- /dev/null +++ b/quarkus-morphium/deployment/src/main/resources/META-INF/quarkus-build-steps.list @@ -0,0 +1,5 @@ +de.caluga.morphium.quarkus.deployment.MorphiumProcessor +de.caluga.morphium.quarkus.deployment.MorphiumDataProcessor +de.caluga.morphium.quarkus.deployment.MorphiumDevServicesProcessor +de.caluga.morphium.quarkus.deployment.MorphiumMigrationProcessor +de.caluga.morphium.quarkus.deployment.MorphiumDevUIProcessor diff --git a/quarkus-morphium/deployment/src/main/resources/dev-ui/qwc-morphium-connection.js b/quarkus-morphium/deployment/src/main/resources/dev-ui/qwc-morphium-connection.js new file mode 100644 index 000000000..36350c952 --- /dev/null +++ b/quarkus-morphium/deployment/src/main/resources/dev-ui/qwc-morphium-connection.js @@ -0,0 +1,59 @@ +import { LitElement, html, css } from 'lit'; +import { JsonRpc } from 'jsonrpc'; + +export class QwcMorphiumConnection extends LitElement { + + jsonRpc = new JsonRpc(this); + + static properties = { + _rows: { state: true }, + _loading: { state: true } + }; + + static styles = css` + :host { + display: block; + padding: 1em; + } + vaadin-grid { + width: 100%; + } + `; + + constructor() { + super(); + this._rows = []; + this._loading = true; + } + + connectedCallback() { + super.connectedCallback(); + this.jsonRpc.getConnectionInfo() + .then(response => { + this._rows = Array.isArray(response?.result) ? response.result : []; + }) + .catch(error => { + console.error('Failed to load connection info', error); + this._rows = [{ + Property: 'Status', + Value: 'Unable to load connection info: ' + (error?.message ?? 'unknown error') + }]; + }) + .finally(() => { + this._loading = false; + }); + } + + render() { + if (this._loading) { + return html`Loading connection info...`; + } + return html` + + + + `; + } +} + +customElements.define('qwc-morphium-connection', QwcMorphiumConnection); diff --git a/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumDataProcessorCustomMethodsTest.java b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumDataProcessorCustomMethodsTest.java new file mode 100644 index 000000000..af5a72879 --- /dev/null +++ b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumDataProcessorCustomMethodsTest.java @@ -0,0 +1,430 @@ +package de.caluga.morphium.quarkus.deployment; + +import de.caluga.morphium.annotations.Entity; +import de.caluga.morphium.annotations.Id; +import de.caluga.morphium.data.MorphiumRepository; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; +import io.quarkus.gizmo.ClassCreator; +import io.quarkus.gizmo.ClassOutput; +import jakarta.data.repository.By; +import jakarta.data.repository.Delete; +import jakarta.data.repository.Repository; + +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.DotName; +import org.jboss.jandex.Index; +import org.jboss.jandex.IndexView; +import org.jboss.jandex.Indexer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Regression tests for {@link MorphiumDataProcessor#generateCustomQueryMethods} covering the + * four Blocker-2 follow-up findings from the maintainer review of PR #267: + * + *

      + *
    1. BEFUND 1: the "skip default/abstract" guard must skip anything that is NOT abstract + * (private interface helper methods included), not just {@code isDefault()} methods -- + * and must additionally skip abstract redeclarations of toString()/equals()/hashCode().
    2. + *
    3. BEFUND 4: the {@code @By} parameter-name fallback (Jakarta Data §4.6.1) must also apply + * in the {@code @Delete} path, not just the {@code @Find} path.
    4. + *
    5. BEFUND 2: a parameter/@By-condition {@code @Delete} method with an unsupported return + * type (boolean, Integer, Long, ...) must fail the *build*, not produce bytecode that + * throws VerifyError at class-load time.
    6. + *
    7. BEFUND 3: an abstract method inherited from a *custom* super-interface (not one of the + * standard Jakarta Data / Morphium repository interfaces) must be picked up by the + * generation loop, not silently skipped because {@code ClassInfo.methods()} only returns + * directly-declared methods.
    8. + *
    + * + *

    These tests build a real Jandex index from actual compiled test-fixture classes and invoke + * the package-private/private processor methods directly (via reflection where needed), following + * the same pattern as {@link MorphiumProcessorReflectionTest}. + */ +@DisplayName("MorphiumDataProcessor — custom @Repository method generation (Blocker 2 follow-ups)") +class MorphiumDataProcessorCustomMethodsTest { + + // ----------------------------------------------------------------- + // Fixtures + // ----------------------------------------------------------------- + + @Entity + public static class FixtureEntity { + @Id + public String id; + public String name; + public String auditor; + } + + /** + * BEFUND 1 fixture: a repository interface with a private interface helper method (legal + * since Java 9, always has a body -- Jandex's isDefault() does NOT consider it "default") + * and an abstract redeclaration of toString(). Neither must be treated as an "unsupported + * repository method" needing generation. + */ + @Repository + public interface PrivateHelperAndToStringRepository extends MorphiumRepository { + // Abstract redeclaration of Object.toString() -- legal, must be skipped (Object supplies it). + @Override + String toString(); + + // Private interface helper method -- has a body, is NOT "default" per Jandex's isDefault() + // (which requires public && !static && !abstract), so guarding on isDefault() alone let this + // fall through to the "unsupported method" exception. Guarding on "not abstract" fixes it. + private String helper() { + return "unused"; + } + + List findByName(String name); + } + + /** + * BEFUND 4 fixture: a @Delete method that relies purely on the parameter name (no @By + * annotation) to specify the delete condition. + */ + @Repository + public interface DeleteByParamNameRepository extends MorphiumRepository { + @Delete + long deleteByName(String name); + } + + /** Same as above, but using an explicit @By annotation (control case, already worked before). */ + @Repository + public interface DeleteByAnnotatedRepository extends MorphiumRepository { + @Delete + void deleteWhere(@By("name") String name); + } + + /** + * Silent-data-loss bug fixture: a @Delete method with a single ENTITY-typed parameter + * (Jakarta Data lifecycle-delete shape, jakarta.data-api 1.0.1 Delete javadoc). This must be + * treated as an entity-lifecycle delete (doDelete(entity)), never as a parameter-name + * @By-condition delete. + * + *

    Boolean is deliberately used as the return type here as a build-time discriminator + * (same technique as {@code DeleteBadReturnTypeRepository} above): boolean is invalid for the + * parameter/@By-condition delete branch (which requires void/int/long) but irrelevant for the + * entity-lifecycle branch (which unconditionally returns void). If the entity parameter were + * misclassified as a condition (the bug), generation would fail with "Unsupported @Delete + * method ... boolean" here; correct handling reaches the entity branch and succeeds. + */ + @Repository + public interface DeleteEntityParamRepository extends MorphiumRepository { + @Delete + boolean remove(FixtureEntity entity); + } + + /** + * Silent-data-loss bug fixture, mixed case: a @Delete method that combines an entity-typed + * parameter with an explicit @By-condition parameter in the same method. Jakarta Data does + * not define semantics for this combination (a @Delete method has either exactly one + * entity/List/array lifecycle parameter, or parameter/@By conditions -- not both), so this + * must be rejected at build time. + */ + @Repository + public interface DeleteMixedEntityAndConditionRepository extends MorphiumRepository { + @Delete + void remove(FixtureEntity entity, @By("name") String name); + } + + /** + * Fixture purely for exercising {@code isEntityParameter} directly against every Jandex type + * shape it must recognize (entity, array-of-entity, List/Collection/Iterable-of-entity) and + * reject (a plain String, a List of Strings). Not a @Repository/@Delete method -- just a + * vehicle to obtain real Jandex {@code Type} instances for each parameter shape. + */ + public interface EntityParamShapesRepository { + void single(FixtureEntity e); + + void array(FixtureEntity[] es); + + void list(List es); + + void collection(java.util.Collection es); + + void iterable(Iterable es); + + void byName(String name); + + void listOfStrings(List names); + } + + /** + * BEFUND 2 fixture: a parameter/@By-condition @Delete method with an unsupported return type + * (boolean is not void/int/long per Jakarta Data). Must be rejected at build time. + */ + @Repository + public interface DeleteBadReturnTypeRepository extends MorphiumRepository { + @Delete + boolean deleteByName(String name); + } + + /** + * BEFUND 3 fixtures: a custom super-interface declaring an abstract method NOT related to + * any standard Jakarta Data / Morphium repository interface. The repository extends both + * this custom interface and MorphiumRepository. + */ + public interface WithAudit { + List findByAuditor(String auditor); + } + + @Repository + public interface AuditedRepository extends MorphiumRepository, WithAudit { + List findByName(String name); + } + + // ----------------------------------------------------------------- + // Infrastructure (same pattern as MorphiumProcessorReflectionTest) + // ----------------------------------------------------------------- + + private static IndexView buildIndex(Class... classes) throws IOException { + Indexer indexer = new Indexer(); + for (Class c : classes) { + String resource = c.getName().replace('.', '/') + ".class"; + try (InputStream in = c.getClassLoader().getResourceAsStream(resource)) { + indexer.index(in); + } + } + return indexer.complete(); + } + + private static class CollectingProducer implements BuildProducer { + final Set registeredClassNames = new HashSet<>(); + + @Override + public void produce(ReflectiveClassBuildItem item) { + registeredClassNames.addAll(item.getClassNames()); + } + } + + /** No-op Gizmo ClassOutput -- these tests only care about build-time exceptions/behavior, + * not about loading the generated class. */ + private static class NoopClassOutput implements ClassOutput { + @Override + public void write(String className, byte[] data) { + // discard -- we only assert on build-time exceptions/absence thereof + } + } + + private static Set entityFieldsOf(Class entityClass, IndexView index) throws Exception { + ClassInfo entityInfo = index.getClassByName(DotName.createSimple(entityClass.getName())); + Method m = MorphiumDataProcessor.class.getDeclaredMethod( + "collectEntityFields", ClassInfo.class, IndexView.class); + m.setAccessible(true); + @SuppressWarnings("unchecked") + Set fields = (Set) m.invoke(new MorphiumDataProcessor(), entityInfo, index); + return fields; + } + + /** + * Invokes the private {@code generateCustomQueryMethods} for the given repository interface + * against a real Gizmo {@link ClassCreator}, mirroring exactly what + * {@code generateImpl}/{@code MorphiumDataProcessor} does at build time. Any + * {@code IllegalStateException} thrown during generation propagates as the cause of an + * {@link InvocationTargetException}. + */ + private static void generate(Class repoInterfaceClass, IndexView index) throws Exception { + ClassInfo repoInfo = index.getClassByName(DotName.createSimple(repoInterfaceClass.getName())); + Set entityFields = entityFieldsOf(FixtureEntity.class, index); + CollectingProducer reflectiveClasses = new CollectingProducer(); + + Method m = MorphiumDataProcessor.class.getDeclaredMethod( + "generateCustomQueryMethods", ClassCreator.class, ClassInfo.class, IndexView.class, + String.class, Set.class, BuildProducer.class); + m.setAccessible(true); + + try (ClassCreator cc = ClassCreator.builder() + .classOutput(new NoopClassOutput()) + .className(repoInterfaceClass.getName() + "_MorphiumImplTest") + .superClass(de.caluga.morphium.data.AbstractMorphiumRepository.class.getName()) + .interfaces(repoInterfaceClass.getName()) + .build()) { + try { + m.invoke(new MorphiumDataProcessor(), cc, repoInfo, index, + FixtureEntity.class.getName(), entityFields, reflectiveClasses); + } catch (InvocationTargetException e) { + if (e.getCause() instanceof RuntimeException re) { + throw re; + } + throw e; + } + } + } + + // ----------------------------------------------------------------- + // BEFUND 1 + // ----------------------------------------------------------------- + + @Test + @DisplayName("BEFUND 1: a private interface helper method + abstract toString() redeclaration must NOT break the build") + void privateHelperAndAbstractToString_doNotBreakGeneration() throws Exception { + IndexView index = buildIndex(FixtureEntity.class, PrivateHelperAndToStringRepository.class, + MorphiumRepository.class); + + // Must not throw -- this is the core regression: previously the private helper method + // (not "default" per Jandex isDefault()) fell through to the "unsupported method" + // IllegalStateException, breaking the build for entirely legal user code. + generate(PrivateHelperAndToStringRepository.class, index); + } + + // ----------------------------------------------------------------- + // BEFUND 4 + // ----------------------------------------------------------------- + + @Test + @DisplayName("BEFUND 4: @Delete method relying on parameter name (no @By) is treated as a condition-delete, not entity-delete") + void deleteByParamNameFallback_isTreatedAsConditionDelete() throws Exception { + IndexView index = buildIndex(FixtureEntity.class, DeleteByParamNameRepository.class, + DeleteByAnnotatedRepository.class, MorphiumRepository.class); + + // Must not throw: previously this fell into the "entity parameter delete" branch, + // which would compile fine here (single String param delegated to doDelete(Object)) + // but attempt to delete a String as an entity at runtime. We can't directly assert the + // internal hasByParams flag (private local var), so we assert indirectly: the identical + // shape with an explicit @By annotation must generate without error too, and a + // regression that reintroduces "entity-parameter delete" behavior for this method would + // still compile a method (just the wrong one) -- covered end-to-end by BEFUND 2's test + // below, which specifically fails when the parameter-name path incorrectly falls through + // the "entity delete" branch for a non-void/int/long return type. + generate(DeleteByParamNameRepository.class, index); + generate(DeleteByAnnotatedRepository.class, index); + } + + // ----------------------------------------------------------------- + // Silent-data-loss bug: @Delete with entity-typed parameter + // ----------------------------------------------------------------- + + @Test + @DisplayName("isEntityParameter: recognizes entity, entity[], List, Collection, Iterable; rejects String and List") + void isEntityParameter_recognizesAllEntityShapesAndRejectsNonEntityShapes() throws Exception { + IndexView index = buildIndex(FixtureEntity.class, EntityParamShapesRepository.class); + ClassInfo repoInfo = index.getClassByName(DotName.createSimple(EntityParamShapesRepository.class.getName())); + String entityClassName = FixtureEntity.class.getName(); + + Method isEntityParameter = MorphiumDataProcessor.class.getDeclaredMethod( + "isEntityParameter", org.jboss.jandex.Type.class, String.class); + isEntityParameter.setAccessible(true); + MorphiumDataProcessor processor = new MorphiumDataProcessor(); + + Map paramTypeByMethodName = new HashMap<>(); + for (org.jboss.jandex.MethodInfo m : repoInfo.methods()) { + paramTypeByMethodName.put(m.name(), m.parameterType(0)); + } + + assertThat((Boolean) isEntityParameter.invoke(processor, paramTypeByMethodName.get("single"), entityClassName)) + .as("plain entity parameter").isTrue(); + assertThat((Boolean) isEntityParameter.invoke(processor, paramTypeByMethodName.get("array"), entityClassName)) + .as("entity[] parameter").isTrue(); + assertThat((Boolean) isEntityParameter.invoke(processor, paramTypeByMethodName.get("list"), entityClassName)) + .as("List parameter").isTrue(); + assertThat((Boolean) isEntityParameter.invoke(processor, paramTypeByMethodName.get("collection"), entityClassName)) + .as("Collection parameter").isTrue(); + assertThat((Boolean) isEntityParameter.invoke(processor, paramTypeByMethodName.get("iterable"), entityClassName)) + .as("Iterable parameter").isTrue(); + assertThat((Boolean) isEntityParameter.invoke(processor, paramTypeByMethodName.get("byName"), entityClassName)) + .as("plain String parameter must NOT be treated as an entity parameter").isFalse(); + assertThat((Boolean) isEntityParameter.invoke(processor, paramTypeByMethodName.get("listOfStrings"), entityClassName)) + .as("List must NOT be treated as an entity parameter").isFalse(); + } + + @Test + @DisplayName("silent data loss fix: @Delete method with an ENTITY parameter must NOT be generated as a condition-delete") + void deleteWithEntityParameter_isNotTreatedAsConditionDelete() throws Exception { + IndexView index = buildIndex(FixtureEntity.class, DeleteEntityParamRepository.class, + MorphiumRepository.class); + + // The return type here is boolean, which is invalid for the parameter/@By-condition + // delete branch (Jakarta Data requires void/int/long there) but is perfectly fine for the + // entity-lifecycle branch (which always returns void, ignoring the declared boolean -- + // the same as the pre-existing single-entity-parameter branch already does). Before the + // fix, the parameter-name fallback wrongly classified the FixtureEntity parameter as a + // @By condition, hit the return-type guard, and this call would throw + // "Unsupported @Delete method ... boolean". After the fix it must generate cleanly, + // proving the entity-lifecycle branch (doDelete(entity)) was chosen instead. + generate(DeleteEntityParamRepository.class, index); + } + + @Test + @DisplayName("silent data loss fix: this is the exact regression case -- deleteByParamNameFallback must stay a condition-delete, entity-param delete must stay an entity-delete") + void deleteByParamNameFallback_and_deleteWithEntityParameter_areClearlyDistinguished() throws Exception { + IndexView index = buildIndex(FixtureEntity.class, DeleteByParamNameRepository.class, + DeleteEntityParamRepository.class, MorphiumRepository.class); + + // Condition-delete (String parameter, no @By): must still work, going through the + // parameter/@By branch (unaffected by the entity-parameter exception). + generate(DeleteByParamNameRepository.class, index); + + // Entity-parameter delete (FixtureEntity parameter, no @By): must go through the + // entity-lifecycle branch. Discriminated the same way as the test above -- a boolean + // return type on this method would fail the build if it were misrouted into the + // condition-delete branch. + generate(DeleteEntityParamRepository.class, index); + } + + @Test + @DisplayName("mixed entity-parameter + @By-condition @Delete method is rejected at BUILD time (unspecified by Jakarta Data)") + void deleteWithMixedEntityAndConditionParameters_failsAtBuildTime() throws Exception { + IndexView index = buildIndex(FixtureEntity.class, DeleteMixedEntityAndConditionRepository.class, + MorphiumRepository.class); + + assertThatThrownBy(() -> generate(DeleteMixedEntityAndConditionRepository.class, index)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Unsupported @Delete method") + .hasMessageContaining("mixes an entity-typed parameter"); + } + + // ----------------------------------------------------------------- + // BEFUND 2 + // ----------------------------------------------------------------- + + @Test + @DisplayName("BEFUND 2: @Delete with boolean return type on a parameter/@By-condition method fails the BUILD, not VerifyError at class-load") + void deleteWithUnsupportedReturnType_failsAtBuildTime() throws Exception { + IndexView index = buildIndex(FixtureEntity.class, DeleteBadReturnTypeRepository.class, + MorphiumRepository.class); + + // This method has a single String parameter with NO @By annotation -- it relies purely + // on the parameter-name fallback (BEFUND 4) to be recognized as a condition-delete. If + // BEFUND 4 were not fixed, this would incorrectly be treated as an "entity delete" and + // NOT hit the return-type guard at all (masking BEFUND 2). Both fixes are exercised + // together here, which is the actual failure mode described in BEFUND 2/4. + assertThatThrownBy(() -> generate(DeleteBadReturnTypeRepository.class, index)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Unsupported @Delete method") + .hasMessageContaining("boolean"); + } + + // ----------------------------------------------------------------- + // BEFUND 3 + // ----------------------------------------------------------------- + + @Test + @DisplayName("BEFUND 3: abstract method inherited from a custom super-interface is generated, not silently skipped") + void inheritedCustomInterfaceMethod_isGenerated() throws Exception { + IndexView index = buildIndex(FixtureEntity.class, AuditedRepository.class, + WithAudit.class, MorphiumRepository.class); + + // Must not throw, and -- more importantly -- must actually invoke code generation for + // findByAuditor(), which is only DECLARED on WithAudit, not on AuditedRepository itself. + // We verify this indirectly: generation succeeds (no AbstractMethodError-causing gap) + // for a repository whose repoInterface.methods() call alone would NOT have surfaced + // findByAuditor() at all before the BEFUND 3 fix (repoInterface.methods() only returns + // directly-declared methods in Jandex). + generate(AuditedRepository.class, index); + } +} diff --git a/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumDevServicesConfigDefaultsTest.java b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumDevServicesConfigDefaultsTest.java new file mode 100644 index 000000000..bfcd97ac2 --- /dev/null +++ b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumDevServicesConfigDefaultsTest.java @@ -0,0 +1,137 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.deployment; + +import io.smallrye.config.SmallRyeConfig; +import io.smallrye.config.SmallRyeConfigBuilder; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for the {@link MorphiumDevServicesBuildTimeConfig} {@code @ConfigMapping}. + * + *

    Uses SmallRye Config directly (no Quarkus container required) to verify that + * all {@code @WithDefault} values are set correctly and that property-name mapping + * (e.g. {@code replica-set} → {@code replicaSet()}) works as expected. + */ +@DisplayName("MorphiumDevServicesBuildTimeConfig – defaults and overrides") +class MorphiumDevServicesConfigDefaultsTest { + + // ------------------------------------------------------------------------- + // Default values – no explicit config source, only @WithDefault applies + // ------------------------------------------------------------------------- + + private static MorphiumDevServicesBuildTimeConfig defaults; + + @BeforeAll + static void buildDefaultConfig() { + SmallRyeConfig sr = new SmallRyeConfigBuilder() + .withMapping(MorphiumDevServicesBuildTimeConfig.class) + .build(); + defaults = sr.getConfigMapping(MorphiumDevServicesBuildTimeConfig.class); + } + + @Test + @DisplayName("enabled() defaults to true") + void enabled_defaultsToTrue() { + assertThat(defaults.enabled()).isTrue(); + } + + @Test + @DisplayName("imageName() defaults to 'mongo:8'") + void imageName_defaultsToMongo8() { + assertThat(defaults.imageName()).isEqualTo("mongo:8"); + } + + @Test + @DisplayName("databaseName() defaults to 'morphium-dev'") + void databaseName_defaultsToMorphiumDev() { + assertThat(defaults.databaseName()).isEqualTo("morphium-dev"); + } + + @Test + @DisplayName("replicaSet() defaults to true") + void replicaSet_defaultsToTrue() { + assertThat(defaults.replicaSet()).isTrue(); + } + + // ------------------------------------------------------------------------- + // Overrides – verify property-name mapping and value parsing + // ------------------------------------------------------------------------- + + @Test + @DisplayName("replica-set property maps to replicaSet() and accepts 'false'") + void replicaSet_canBeDisabledViaProperty() { + SmallRyeConfig sr = new SmallRyeConfigBuilder() + .withMapping(MorphiumDevServicesBuildTimeConfig.class) + .withDefaultValue("quarkus.morphium.devservices.replica-set", "false") + .build(); + MorphiumDevServicesBuildTimeConfig cfg = + sr.getConfigMapping(MorphiumDevServicesBuildTimeConfig.class); + assertThat(cfg.replicaSet()).isFalse(); + } + + @Test + @DisplayName("enabled property can be set to false") + void enabled_canBeDisabled() { + SmallRyeConfig sr = new SmallRyeConfigBuilder() + .withMapping(MorphiumDevServicesBuildTimeConfig.class) + .withDefaultValue("quarkus.morphium.devservices.enabled", "false") + .build(); + MorphiumDevServicesBuildTimeConfig cfg = + sr.getConfigMapping(MorphiumDevServicesBuildTimeConfig.class); + assertThat(cfg.enabled()).isFalse(); + } + + @Test + @DisplayName("imageName can be overridden to a custom image") + void imageName_canBeOverridden() { + SmallRyeConfig sr = new SmallRyeConfigBuilder() + .withMapping(MorphiumDevServicesBuildTimeConfig.class) + .withDefaultValue("quarkus.morphium.devservices.image-name", "mongo:7") + .build(); + MorphiumDevServicesBuildTimeConfig cfg = + sr.getConfigMapping(MorphiumDevServicesBuildTimeConfig.class); + assertThat(cfg.imageName()).isEqualTo("mongo:7"); + } + + @Test + @DisplayName("databaseName can be overridden") + void databaseName_canBeOverridden() { + SmallRyeConfig sr = new SmallRyeConfigBuilder() + .withMapping(MorphiumDevServicesBuildTimeConfig.class) + .withDefaultValue("quarkus.morphium.devservices.database-name", "my-dev-db") + .build(); + MorphiumDevServicesBuildTimeConfig cfg = + sr.getConfigMapping(MorphiumDevServicesBuildTimeConfig.class); + assertThat(cfg.databaseName()).isEqualTo("my-dev-db"); + } + + @Test + @DisplayName("replica-set=true is idempotent (same as default)") + void replicaSet_trueIsIdempotent() { + SmallRyeConfig sr = new SmallRyeConfigBuilder() + .withMapping(MorphiumDevServicesBuildTimeConfig.class) + .withDefaultValue("quarkus.morphium.devservices.replica-set", "true") + .build(); + MorphiumDevServicesBuildTimeConfig cfg = + sr.getConfigMapping(MorphiumDevServicesBuildTimeConfig.class); + assertThat(cfg.replicaSet()).isTrue(); + } +} diff --git a/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumDevServicesProcessorTest.java b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumDevServicesProcessorTest.java new file mode 100644 index 000000000..eac0bec34 --- /dev/null +++ b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumDevServicesProcessorTest.java @@ -0,0 +1,107 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.deployment; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.mongodb.MongoDBContainer; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link MorphiumDevServicesProcessor} and {@link MongoDBStartable}. + * + *

    These tests do NOT start Docker containers. They verify: + *

      + *
    • Mode-detection contract: {@code MongoDBContainer} IS-A {@code GenericContainer} + * but a plain {@code GenericContainer} is NOT-A {@code MongoDBContainer}
    • + *
    • {@code MongoDBStartable} construction and property access
    • + *
    • {@code CapturedConfig} equality for container reuse decisions
    • + *
    + */ +@DisplayName("MorphiumDevServicesProcessor – static volatile container reuse") +class MorphiumDevServicesProcessorTest { + + // ------------------------------------------------------------------------- + // Mode-detection type contract (documentation tests) + // ------------------------------------------------------------------------- + + @Test + @DisplayName("[contract] MongoDBContainer extends GenericContainer") + void typeContract_mongoDbContainerExtendsGenericContainer() { + assertThat(GenericContainer.class.isAssignableFrom(MongoDBContainer.class)) + .as("MongoDBContainer must extend GenericContainer") + .isTrue(); + } + + @Test + @DisplayName("[contract] GenericContainer is NOT a MongoDBContainer") + void typeContract_genericContainerIsNotMongoDBContainer() { + assertThat(MongoDBContainer.class.isAssignableFrom(GenericContainer.class)) + .as("A plain GenericContainer must NOT be assignable to MongoDBContainer") + .isFalse(); + } + + // ------------------------------------------------------------------------- + // MongoDBStartable construction + // ------------------------------------------------------------------------- + + @Test + @DisplayName("MongoDBStartable stores replicaSet flag") + void startable_storesReplicaSetFlag() { + var standalone = new MongoDBStartable("mongo:8", false); + assertThat(standalone.isReplicaSet()).isFalse(); + + var replicaSet = new MongoDBStartable("mongo:8", true); + assertThat(replicaSet.isReplicaSet()).isTrue(); + } + + @Test + @DisplayName("MongoDBStartable.getContainerId() returns null before start") + void startable_containerIdNullBeforeStart() { + var startable = new MongoDBStartable("mongo:8", false); + assertThat(startable.getContainerId()).isNull(); + } + + // ------------------------------------------------------------------------- + // CapturedConfig equality (drives container reuse) + // ------------------------------------------------------------------------- + + @Test + @DisplayName("CapturedConfig equals when all fields match") + void capturedConfig_equalWhenSame() { + var a = new MorphiumDevServicesProcessor.CapturedConfig("mongo:8", true, "test-db"); + var b = new MorphiumDevServicesProcessor.CapturedConfig("mongo:8", true, "test-db"); + assertThat(a).isEqualTo(b); + } + + @Test + @DisplayName("CapturedConfig not equal when image differs") + void capturedConfig_notEqualWhenImageDiffers() { + var a = new MorphiumDevServicesProcessor.CapturedConfig("mongo:7", true, "test-db"); + var b = new MorphiumDevServicesProcessor.CapturedConfig("mongo:8", true, "test-db"); + assertThat(a).isNotEqualTo(b); + } + + @Test + @DisplayName("CapturedConfig not equal when replicaSet differs") + void capturedConfig_notEqualWhenReplicaSetDiffers() { + var a = new MorphiumDevServicesProcessor.CapturedConfig("mongo:8", false, "test-db"); + var b = new MorphiumDevServicesProcessor.CapturedConfig("mongo:8", true, "test-db"); + assertThat(a).isNotEqualTo(b); + } +} diff --git a/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessorReflectionTest.java b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessorReflectionTest.java new file mode 100644 index 000000000..04f7d6cd0 --- /dev/null +++ b/quarkus-morphium/deployment/src/test/java/de/caluga/morphium/quarkus/deployment/MorphiumProcessorReflectionTest.java @@ -0,0 +1,162 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.deployment; + +import de.caluga.morphium.DefaultNameProvider; +import de.caluga.morphium.NameProvider; +import de.caluga.morphium.annotations.Entity; +import de.caluga.morphium.annotations.Id; +import io.quarkus.deployment.annotations.BuildProducer; +import io.quarkus.deployment.builditem.nativeimage.ReflectiveClassBuildItem; +import org.jboss.jandex.AnnotationInstance; +import org.jboss.jandex.ClassInfo; +import org.jboss.jandex.DotName; +import org.jboss.jandex.Index; +import org.jboss.jandex.IndexView; +import org.jboss.jandex.Indexer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Regression tests for {@link MorphiumProcessor#registerSubclasses} and + * {@link MorphiumProcessor#registerCustomNameProvider} (should-fix #10). + * + *

    Builds a real Jandex index from actual compiled test-fixture classes (not a mock), so + * these tests exercise the exact same {@code IndexView}/{@code AnnotationInstance} API contract + * the real build step relies on. + */ +@DisplayName("MorphiumProcessor — native-image reflection registration (should-fix #10)") +class MorphiumProcessorReflectionTest { + + /** Base entity, annotated. */ + @Entity + public static class BaseAnimal { + @Id + public String id; + } + + /** Subclass with NO annotation of its own -- Morphium treats it as an entity anyway + * via AnnotationAndReflectionHelper.isAnnotationPresentInHierarchy(). */ + public static class DogSubclass extends BaseAnimal { + public String breed; + } + + /** Transitive subclass, two levels down. */ + public static class PuppySubclass extends DogSubclass { + public int ageMonths; + } + + /** Custom NameProvider a user might point @Entity(nameProvider = ...) at. */ + public static class CustomNameProvider implements NameProvider { + @Override + public String getCollectionName(Class type, de.caluga.morphium.objectmapping.MorphiumObjectMapper om, + boolean translateCamelCase, boolean useFQN, + String specifiedName, de.caluga.morphium.Morphium morphium) { + return "custom"; + } + } + + @Entity(nameProvider = CustomNameProvider.class) + public static class EntityWithCustomNameProvider { + @Id + public String id; + } + + @Entity // uses the default nameProvider() = DefaultNameProvider.class + public static class EntityWithDefaultNameProvider { + @Id + public String id; + } + + private static IndexView buildIndex(Class... classes) throws IOException { + Indexer indexer = new Indexer(); + for (Class c : classes) { + String resource = c.getName().replace('.', '/') + ".class"; + try (InputStream in = c.getClassLoader().getResourceAsStream(resource)) { + indexer.index(in); + } + } + return indexer.complete(); + } + + private static class CollectingProducer implements BuildProducer { + final Set registeredClassNames = new HashSet<>(); + + @Override + public void produce(ReflectiveClassBuildItem item) { + registeredClassNames.addAll(item.getClassNames()); + } + } + + @Test + @DisplayName("registerSubclasses: registers direct and transitive subclasses, not just the annotated base") + void registerSubclasses_registersDirectAndTransitiveSubclasses() throws IOException { + IndexView index = buildIndex(BaseAnimal.class, DogSubclass.class, PuppySubclass.class); + CollectingProducer producer = new CollectingProducer(); + MorphiumProcessor processor = new MorphiumProcessor(); + + ClassInfo baseInfo = index.getClassByName(DotName.createSimple(BaseAnimal.class.getName())); + processor.registerSubclasses(baseInfo, index, producer, new HashSet<>()); + + assertThat(producer.registeredClassNames) + .as("both the direct subclass and the transitive (grand-child) subclass must be registered") + .contains(DogSubclass.class.getName(), PuppySubclass.class.getName()); + } + + @Test + @DisplayName("registerCustomNameProvider: registers a custom nameProvider class") + void registerCustomNameProvider_registersCustomProvider() throws IOException { + IndexView index = buildIndex(EntityWithCustomNameProvider.class, CustomNameProvider.class); + CollectingProducer producer = new CollectingProducer(); + MorphiumProcessor processor = new MorphiumProcessor(); + + ClassInfo entityInfo = index.getClassByName(DotName.createSimple(EntityWithCustomNameProvider.class.getName())); + AnnotationInstance entityAnnotation = entityInfo.declaredAnnotation(DotName.createSimple(Entity.class.getName())); + + processor.registerCustomNameProvider(entityAnnotation, producer); + + assertThat(producer.registeredClassNames) + .as("the custom nameProvider class must be registered for reflection") + .contains(CustomNameProvider.class.getName()); + } + + @Test + @DisplayName("registerCustomNameProvider: does NOT re-register the default nameProvider (already registered unconditionally elsewhere)") + void registerCustomNameProvider_skipsDefaultProvider() throws IOException { + IndexView index = buildIndex(EntityWithDefaultNameProvider.class); + CollectingProducer producer = new CollectingProducer(); + MorphiumProcessor processor = new MorphiumProcessor(); + + ClassInfo entityInfo = index.getClassByName(DotName.createSimple(EntityWithDefaultNameProvider.class.getName())); + AnnotationInstance entityAnnotation = entityInfo.declaredAnnotation(DotName.createSimple(Entity.class.getName())); + + processor.registerCustomNameProvider(entityAnnotation, producer); + + assertThat(producer.registeredClassNames) + .as("DefaultNameProvider is already registered unconditionally by the caller; this method must not duplicate it") + .doesNotContain(DefaultNameProvider.class.getName()); + } +} diff --git a/quarkus-morphium/docs/antora.yml b/quarkus-morphium/docs/antora.yml new file mode 100644 index 000000000..02edd242a --- /dev/null +++ b/quarkus-morphium/docs/antora.yml @@ -0,0 +1,9 @@ +name: quarkus-morphium +title: Morphium MongoDB ORM +version: '6.3' +display_version: 6.3.0-SNAPSHOT +nav: + - modules/ROOT/nav.adoc +asciidoc: + attributes: + page-toclevels: 3 diff --git a/quarkus-morphium/docs/gaps/JAKARTA-DATA.md b/quarkus-morphium/docs/gaps/JAKARTA-DATA.md new file mode 100644 index 000000000..42a56e74f --- /dev/null +++ b/quarkus-morphium/docs/gaps/JAKARTA-DATA.md @@ -0,0 +1,408 @@ +# Jakarta Data 1.0 -- Gap Analysis & Improvement Roadmap + +> **quarkus-morphium** Jakarta Data provider +> Last updated: 2026-03-15 +> **Status as of the morphium-jakarta-data/quarkus-morphium module merge (2026-08):** every +> numbered gap below (#1-#9) is marked DONE and already implemented and tested in +> `morphium-jakarta-data`/`quarkus-morphium`. The only item still genuinely open is GAP-A6 +> (COUNT DISTINCT / expressions inside aggregates, listed under #8). This document is kept as +> historical context for *how* each gap was closed (implementation notes, MongoDB pipeline +> shapes, deliberate scope decisions) -- read it as an implementation log, not as a list of +> pending work. + +--- + +## Current State + +The Jakarta Data 1.0 integration lives entirely in `quarkus-morphium` (not morphium-core). +Repository implementations are generated at **build time** via Quarkus Gizmo bytecode +generation -- no runtime reflection, GraalVM native-image compatible. + +### What works today + +| Area | Status | Details | +|------|--------|---------| +| Repository interfaces | Full | `DataRepository`, `BasicRepository`, `CrudRepository`, `MorphiumRepository` | +| CRUD annotations | Full | `@Insert`, `@Update`, `@Save`, `@Delete`, `@Find` | +| Query derivation | Good | 15+ operators: Equals, Not, GT/GTE/LT/LTE, Between, In, NotIn, Like, StartsWith, EndsWith, Null, NotNull, True, False | +| JDQL (`@Query`) | Good | WHERE, ORDER BY, BETWEEN, IN, LIKE, IS NULL, named params, SELECT projection, aggregate functions (COUNT/SUM/AVG/MIN/MAX), GROUP BY (single + multi-field), HAVING. | +| Return types | Good | `List`, `Stream`, `Optional`, `Page`, `CompletionStage`, `long`, `boolean`, `void`, single `T` | +| Sorting | Full | `Sort`, `Order`, `@OrderBy`, JDQL ORDER BY | +| Pagination | Good | `PageRequest`, `Limit`, `MorphiumPage`. No keyset/cursor pagination | +| StaticMetamodel | Full | Auto-generated `Entity_` classes for type-safe field refs | +| Morphium transparency | Full | `@Version`, `@Cache`, `@Reference`, lifecycle callbacks all work through repos | + +### Key files + +| File | Purpose | +|------|---------| +| `runtime/.../data/AbstractMorphiumRepository.java` | Base class for generated repo impls | +| `runtime/.../data/MorphiumRepository.java` | Morphium-specific extension interface | +| `runtime/.../data/MethodNameParser.java` | Query derivation from method names | +| `runtime/.../data/QueryDescriptor.java` | Parsed query representation | +| `runtime/.../data/QueryExecutor.java` | Query execution orchestration | +| `runtime/.../data/FindMethodBridge.java` | `@Find`/`@By`/`@OrderBy` execution | +| `runtime/.../data/JdqlParser.java` | JDQL query string parsing | +| `runtime/.../data/JdqlMethodBridge.java` | `@Query` JDQL execution | +| `runtime/.../data/MorphiumPage.java` | `Page` implementation | +| `runtime/.../data/SortMapper.java` | Jakarta Data Sort -> MongoDB sort | +| `deployment/.../MorphiumDataProcessor.java` | Build-time bytecode generation (~900 LOC) | + +--- + +## Gaps & Roadmap + +### Quick Wins (< 1 day each) + +#### #1 Jakarta Data Standard Exceptions +- **Status:** DONE +- **Gap:** No Jakarta Data exceptions thrown. Generic exceptions or null returned instead. +- **Required:** `EmptyResultException`, `NonUniqueResultException`, `EmptyResultException` +- **Impact:** Spec compliance, better error diagnostics for developers +- **Effort:** 2-3 hours +- **Details:** See [Detailed Plan](#1-detailed-plan-jakarta-data-standard-exceptions) below + +#### #2 Missing Query Derivation Operators +- **Status:** DONE +- **Gap:** `Contains`, `Empty`, `Size`, `Matches`/`Regex`, `IgnoreCase` not supported +- **Required by spec:** Contains (collection membership), Empty (collection/string), pattern matching +- **Impact:** Users must fall back to `@Query` JDQL for these common patterns +- **Effort:** 3-4 hours +- **Files:** `MethodNameParser.java`, `QueryExecutor.java` + +#### #3 `deleteAll()` no-arg + `deleteBy*` Query Derivation +- **Status:** DONE +- **Gap:** `deleteAll()` (no-arg, delete entire collection) not implemented. `deleteBy*` method name prefix not tested/verified in query derivation. +- **Impact:** Standard CrudRepository method missing +- **Effort:** 2 hours +- **Files:** `AbstractMorphiumRepository.java`, `MorphiumDataProcessor.java`, `QueryMethodBridge.java` + +#### #4 Test Coverage Extension +- **Status:** DONE +- **Gap:** Untested operators: StartsWith, EndsWith, Like (with wildcards), In, NotIn, Null/IsNull, OR combinator, deleteBy derivation, multiple OrderBy fields, exception scenarios +- **Impact:** Quality assurance, regression safety +- **Effort:** 3-4 hours +- **Files:** `integration-tests/src/test/java/.../MorphiumData*Test.java` + +### Medium Effort (1-2 days each) + +#### #5 `CursoredPage` (Keyset Pagination) +- **Status:** DONE +- **Gap:** Only offset-based pagination (`Page` + `PageRequest`). No keyset/cursor pagination. +- **Why it matters:** Offset pagination degrades on large collections (`skip(100000)` is slow in MongoDB). Keyset pagination uses indexed field values for O(1) page jumps. +- **Effort:** 1-2 days +- **Files:** New `MorphiumCursoredPage.java`, changes to `FindMethodBridge.java`, `MorphiumDataProcessor.java` + +#### #6 Stream Support in Repositories +- **Status:** DONE +- **Gap:** `Stream` return type works but delegates to `asList().stream()` (eager loading). Should use `Query.stream()` (lazy cursor-backed) for large result sets. +- **Impact:** Memory-efficient processing of large collections via repos +- **Effort:** 0.5 days +- **Files:** `AbstractMorphiumRepository.java`, `FindMethodBridge.java` + +#### #7 JDQL `SELECT` with Projection +- **Status:** DONE +- **Gap:** JDQL always returns full entities. `SELECT name, price FROM Product WHERE ...` not supported. +- **Impact:** Network/memory savings for queries that only need a few fields +- **Effort:** 1 day +- **Files:** `JdqlQuery.java`, `JdqlParser.java`, `JdqlMethodBridge.java` + +### Larger Effort (3+ days) + +#### #8 JDQL Aggregate Functions +- **Status:** DONE (v3 — global aggregation + single/multi-field GROUP BY + HAVING) +- **Gap:** `COUNT()`, `SUM()`, `AVG()`, `MIN()`, `MAX()` not supported in JDQL +- **Impact:** Analytics queries require dropping down to Morphium Aggregation API +- **Effort:** 2-3 days +- **Files:** `JdqlQuery.java`, `JdqlParser.java`, `JdqlMethodBridge.java`, `MorphiumDataProcessor.java` +- **v1 supports:** `SELECT COUNT(this)`, `SELECT SUM(field)`, `SELECT AVG(field)`, `SELECT MIN(field)`, `SELECT MAX(field)` with WHERE clauses. Return types: `long` for COUNT, `double` for SUM/AVG/MIN/MAX. +- **v2 adds:** Single-field GROUP BY with Java Record return type mapping. `SELECT status, COUNT(this), SUM(amount) GROUP BY status` returns `List`. ORDER BY with GROUP BY (field + aggregate references). WHERE + GROUP BY. +- **v3 adds:** Multi-field GROUP BY (`GROUP BY status, customerId`) with compound `_id` → `$project` promotion. HAVING with comparison operators, named params, numeric literals, and AND/OR-combined conditions. HAVING filters are emitted as separate `$match` stages (AND) or a single `$match` with `$or` array (OR) after `$group`. +- **v4 adds:** `COUNT(field)` NULL filtering via `$addFields` + `$cond`/`$ne` before `$group`. `Page` pagination for GROUP BY queries (Java-level, avoids InMemAggregator `$skip` bug). HAVING OR combinator. +- **Remaining limitations:** + - No `COUNT(DISTINCT ...)` or expressions inside aggregates + +##### #8 v1 Known Gaps (GAP-A1 through GAP-A8) + +These are **deliberate scope decisions** for the v1 implementation, not bugs. +Each gap documents: what's missing, why, the required effort, and workarounds. + +###### GAP-A1: GROUP BY (single + multi-field) — DONE + +**Implemented in v2 (single-field) and v3 (multi-field).** `SELECT status, COUNT(this) GROUP BY status` +and `SELECT status, customerId, COUNT(this) GROUP BY status, customerId` both work with Java Record +return types. Record component order must match SELECT clause order (group fields first, then aggregates). +Multi-field GROUP BY uses compound `_id` maps with a `$project` stage to promote sub-fields to top level. + +--- + +###### GAP-A2: HAVING — DONE + +**Implemented in v3 (AND), extended in v4 (OR).** `SELECT status, COUNT(this) GROUP BY status HAVING COUNT(this) > 5` works. +Supports comparison operators (`>`, `>=`, `<`, `<=`, `=`, `!=`), named parameters (`:param`), +numeric literals, and both AND and OR combinators. + +- **AND** (default): conditions are emitted as separate `$match` stages after `$group` (one per condition) + to work around an InMemoryDriver limitation where multi-field `$match` documents short-circuit + on the first matching field (fix submitted as morphium PR #151). +- **OR**: conditions are emitted as a single `$match` stage with a `$or` array. + Example: `HAVING COUNT(this) > 5 OR SUM(amount) >= 1000`. + +--- + +###### GAP-A3: COUNT(field) NULL Filtering — DONE + +**Implemented in v4.** `SELECT COUNT(customerId) WHERE status = 'OPEN'` now correctly counts only +documents where `customerId IS NOT NULL` (standard SQL COUNT semantics). + +**Implementation:** An `$addFields` stage is inserted before `$group` that creates a helper field +(`_cnt_notnull_N`) using `$cond`/`$ne` to produce 1 for non-null values and 0 for null. +The `$group` accumulator then sums this helper field instead of a constant 1. +This avoids modifying `Group.sum()` and works with the InMemory driver (which evaluates +`$cond` via `Expr.evaluate()`). + +--- + +###### GAP-A4: Mixed SELECT (Aggregate + Field Projections) — DONE + +**Implemented in v2.** `SELECT status, SUM(amount) GROUP BY status` works when all plain +fields appear in GROUP BY. Without GROUP BY, mixing still throws `IllegalArgumentException`. + +--- + +###### GAP-A5: Record Return Types for GROUP BY — DONE + +**Implemented in v2.** `List` return types are detected at build time via +Jandex (`superName == java.lang.Record`). Record canonical constructor is invoked via +reflection at runtime. Record component order must match SELECT clause order. + +--- + +###### GAP-A6: No DISTINCT or Expressions Inside Aggregates + +**What's missing:** +- `SELECT COUNT(DISTINCT status) WHERE ...` — DISTINCT within aggregates +- `SELECT SUM(amount * quantity) WHERE ...` — arithmetic expressions within aggregates + +**Why not in v1:** +- DISTINCT requires `$addToSet` + `$size` in the pipeline — complex mapping +- Expressions require `$multiply`/`$add` etc. inside `$group` accumulators +- Parser would need to handle arithmetic expressions within function parentheses + +**Effort:** 2+ days + +**MongoDB pipeline for COUNT DISTINCT:** +```json +[ + { "$group": { "_id": null, "distinctStatuses": { "$addToSet": "$status" } } }, + { "$project": { "count": { "$size": "$distinctStatuses" } } } +] +``` + +--- + +###### GAP-A7: ORDER BY with GROUP BY — DONE + +**Implemented in v2.** ORDER BY in GROUP BY queries adds a `$sort` stage after `$group`. +Supports sorting by group fields (mapped to `_id`) and aggregate function references +(e.g. `ORDER BY COUNT(this) DESC`). ORDER BY is still ignored for global aggregation +(single result). + +--- + +###### GAP-A8: Pagination for GROUP BY Aggregates — DONE + +**Implemented in v4.** `Page` return type with `PageRequest` parameter now works +for GROUP BY queries. Example: `Page countGroupByStatusPaged(PageRequest pageRequest)`. + +**Implementation:** Pagination is applied in Java after the full aggregation completes +(slice the mapped result list) rather than via `$skip`/`$limit` pipeline stages. +This is a deliberate workaround for an InMemAggregator `$skip` bug +(line 1316: `data.subList(idx, data.size() - idx)` — incorrect, should be `data.subList(idx, data.size())`). + +**Build-time:** `Page` return types are now detected by `MorphiumDataProcessor` via Jandex, +extending the existing `List` detection to also cover parameterized `Page` types. + +**Note:** `CursoredPage` is not yet supported for GROUP BY queries. + +--- + +#### #9 `CompletionStage` (Async Repositories) +- **Status:** DONE +- **Gap:** No async/reactive return types. All repository methods are synchronous. +- **Impact:** Non-blocking repository methods for reactive Quarkus applications +- **Effort:** 1 day +- **Files:** `MorphiumDataProcessor.java`, `AbstractMorphiumRepository.java`, `QueryMethodBridge.java`, `FindMethodBridge.java`, `JdqlMethodBridge.java` +- **What works:** `CompletionStage>`, `CompletionStage>`, `CompletionStage` (aggregates) for query derivation (`findBy*Async`), `@Find` annotated methods, and `@Query` JDQL methods. +- **Convention:** Query derivation methods use `*Async` suffix (e.g. `findByStatusAsync`). The "Async" suffix is stripped before method name parsing. +- **Implementation:** Async execution via `CompletableFuture.supplyAsync()` on Morphium's virtual-thread-backed `asyncOperationsThreadPool`. +- **v1 limitations:** + - No `Uni` / SmallRye Mutiny support (would need Mutiny dependency) + - No async CRUD methods (standard `findById`, `save`, etc.) — only custom query/find/jdql methods + - No `CompletionStage>` (Stream is inherently pull-based, conflicts with async push model) + +--- + +## Not Planned + +| Feature | Reason | +|---------|--------| +| Jakarta NoSQL support | Spec too immature (v1.0, 1 impl, not in EE 11). Morphium's annotation model is richer. See analysis in session 2026-03-14. | +| `PageableRepository` interface | Deprecated pattern in Jakarta Data 1.0; pagination via method params (`PageRequest`, `Limit`) is the recommended approach and already supported. | +| JDQL JOINs | MongoDB has no native JOINs. `$lookup` is aggregation-only and doesn't map to JDQL semantics. | +| JDQL subqueries | Same reason -- no native support in MongoDB query language. | + +--- + +## #1 Detailed Plan: Jakarta Data Standard Exceptions + +### Goal + +Throw the correct Jakarta Data exceptions from repository method executions so that +application code can catch standardized exception types instead of getting `null`, +generic `IllegalStateException`, or raw Morphium exceptions. + +### Jakarta Data Exception Types + +From `jakarta.data.exceptions` (verified in `jakarta.data-api:1.0.0`): + +| Exception | When to throw | +|-----------|--------------| +| `EmptyResultException` | A query that expects exactly one result finds none (e.g., `findByEmail(...)` returning `T` not `Optional`, or `findById(K)` returning `T`) | +| `NonUniqueResultException` | A query that expects at most one result finds multiple | +| `EntityExistsException` | Insert fails because entity with same ID already exists | +| `OptimisticLockingFailureException` | `@Version` conflict on update/delete | +| `MappingException` | Entity mapping/conversion fails | +| `DataConnectionException` | Database not reachable | +| `DataException` | Base class for all Jakarta Data exceptions | + +**Note:** There is no `EmptyResultException` in the spec. `EmptyResultException` covers both +query-no-result and findById-not-found scenarios. + +### Current Behavior (what's wrong) + +1. **`FindMethodBridge.java:136`** -- single-entity queries return `null` when not found + - Should throw `EmptyResultException` if return type is `T` (non-Optional, non-null) + - Should return `Optional.empty()` if return type is `Optional` (already correct) + +2. **`FindMethodBridge.java`** -- no check for multiple results on single-entity return + - `query.get()` returns the first match silently even if 100 rows match + - Should throw `NonUniqueResultException` if >1 result and return type is `T` or `Optional` + +3. **`AbstractMorphiumRepository.java:doFindById()`** -- returns `Optional.empty()` (correct for Optional) + - But generated code for `T findById(K)` (non-Optional) also returns null → should throw `EmptyResultException` + +4. **`JdqlMethodBridge.java`** -- same issues as FindMethodBridge for single-result queries + +5. **No `MappingException` wrapping** -- deserialization errors from `ObjectMapperImpl` bubble up as raw exceptions + +### Implementation Plan + +#### Step 1: Add jakarta.data-api dependency check (verify version) + +File: `quarkus-morphium/pom.xml` +- Verify `jakarta.data:jakarta.data-api:1.0.0` is on classpath (already present) +- Verify exception classes are available: `jakarta.data.exceptions.EmptyResultException`, `NonUniqueResultException`, `EmptyResultException`, `MappingException` + +#### Step 2: Modify `FindMethodBridge.java` + +**Single-entity return type (`T`, not `Optional`):** + +```java +// Current (line ~136): +T result = query.get(); +return result; // returns null silently + +// New: +List results = query.limit(2).asList(); +if (results.isEmpty()) { + throw new EmptyResultException("Query returned no result"); +} +if (results.size() > 1) { + throw new NonUniqueResultException("Query returned more than one result"); +} +return results.get(0); +``` + +**Optional return type (`Optional`):** + +```java +// Current: +return Optional.ofNullable(query.get()); + +// New: +List results = query.limit(2).asList(); +if (results.size() > 1) { + throw new NonUniqueResultException("Query returned more than one result"); +} +return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); +``` + +**List/Stream/Page return types:** No changes needed -- multiple results are expected. + +#### Step 3: Modify `JdqlMethodBridge.java` + +Same pattern as FindMethodBridge for single-result JDQL queries. + +#### Step 4: Modify `AbstractMorphiumRepository.java` + +**`doFindById(K id)` method:** + +```java +// Current: +T entity = morphium.findById(entityClass, id); +return Optional.ofNullable(entity); + +// Keep as-is for Optional return type. +// But generated code for T findById(K) needs unwrapping with exception: +``` + +**In `MorphiumDataProcessor.java` (generated findById code):** + +When the declared return type is `T` (not `Optional`), the generated bytecode should: +1. Call `doFindById(id)` which returns `Optional` +2. Call `.orElseThrow(() -> new EmptyResultException("No entity found for given id"))` + +#### Step 5: Add tests + +New test class: `MorphiumDataExceptionTest.java` in integration-tests: + +| Test | Scenario | Expected | +|------|----------|----------| +| `findSingle_noResult_throwsEmptyResult` | `findByEmail("nonexistent")` returns `T` | `EmptyResultException` | +| `findSingle_multipleResults_throwsNonUnique` | `findByCategory("common")` returns `T` with 5 matches | `NonUniqueResultException` | +| `findOptional_noResult_returnsEmpty` | `findOptionalByEmail("nonexistent")` | `Optional.empty()` (no exception) | +| `findOptional_multipleResults_throwsNonUnique` | `findOptionalByCategory("common")` returns `Optional` | `NonUniqueResultException` | +| `findById_notFound_throwsEmptyResult` | `findById("missing-id")` returns `T` | `EmptyResultException` | +| `findById_notFound_optional_returnsEmpty` | `findByIdOptional("missing-id")` | `Optional.empty()` | +| `jdql_noResult_throwsEmptyResult` | `@Query("WHERE email = :e")` returns `T` | `EmptyResultException` | + +#### Step 6: Verify exception class availability + +Check whether `jakarta.data.exceptions` package is in the `jakarta.data-api:1.0.0` JAR. +If the exception classes don't exist in 1.0.0 (they may have been added in 1.0.1 or later), +we define our own that extend `DataException`. + +### Files to modify + +| File | Change | +|------|--------| +| `FindMethodBridge.java` | Add uniqueness check + EmptyResultException for single-entity returns | +| `JdqlMethodBridge.java` | Same pattern for JDQL single-result queries | +| `AbstractMorphiumRepository.java` | No change (Optional return already correct) | +| `MorphiumDataProcessor.java` | Generate `orElseThrow(EmptyResultException)` for non-Optional findById | +| New: `MorphiumDataExceptionTest.java` | 7 integration tests | +| New: test repository interface with single-return methods | Test fixture | + +### Acceptance Criteria + +- [ ] `T findByX(...)` throws `EmptyResultException` when no result +- [ ] `T findByX(...)` throws `NonUniqueResultException` when >1 result +- [ ] `Optional findByX(...)` returns `Optional.empty()` when no result (no exception) +- [ ] `Optional findByX(...)` throws `NonUniqueResultException` when >1 result +- [ ] `T findById(K)` (non-Optional return) throws `EmptyResultException` when not found +- [ ] `Optional findById(K)` returns `Optional.empty()` (no exception) +- [ ] `@Query` JDQL single-result methods follow the same rules +- [ ] `List`, `Stream`, `Page` return types are unaffected +- [ ] All 7 integration tests pass +- [ ] Existing integration tests remain green diff --git a/quarkus-morphium/docs/modules/ROOT/nav.adoc b/quarkus-morphium/docs/modules/ROOT/nav.adoc new file mode 100644 index 000000000..f90fc33c1 --- /dev/null +++ b/quarkus-morphium/docs/modules/ROOT/nav.adoc @@ -0,0 +1,10 @@ +* xref:index.adoc[Introduction] +* xref:getting-started.adoc[Getting Started] +* xref:jakarta-data.adoc[Jakarta Data 1.0] +* xref:configuration.adoc[Configuration Reference] +* xref:entities.adoc[Entities & Annotations] +* xref:transactions.adoc[Transactions] +* xref:dev-services.adoc[Dev Services] +* xref:health-checks.adoc[Health Checks] +* xref:testing.adoc[Testing] +* xref:advanced.adoc[Advanced Topics] diff --git a/quarkus-morphium/docs/modules/ROOT/pages/advanced.adoc b/quarkus-morphium/docs/modules/ROOT/pages/advanced.adoc new file mode 100644 index 000000000..cd0a650cb --- /dev/null +++ b/quarkus-morphium/docs/modules/ROOT/pages/advanced.adoc @@ -0,0 +1,226 @@ += Advanced Topics + +include::./includes/attributes.adoc[] + +[#ssl-tls] +== SSL/TLS Connections + +The extension supports TLS-encrypted connections and X.509 client-certificate authentication. + +=== TLS-Only (Encrypted Transport) + +Enable TLS and provide a truststore to validate the MongoDB server certificate: + +[source,properties] +---- +quarkus.morphium.ssl.enabled=true +quarkus.morphium.ssl.truststore-path=/etc/certs/mongo-truststore.jks +quarkus.morphium.ssl.truststore-password=changeit +---- + +When no truststore is specified, the JVM's default truststore is used (suitable for +certificates signed by well-known CAs, including MongoDB Atlas). + +=== X.509 Mutual TLS (Client Certificate Authentication) + +For X.509 authentication, only `ssl.enabled` and `ssl.auth-mechanism` are required. +The client certificate and CA trust chain can come from the extension-specific keystore / +truststore properties **or** from the global JVM stores (`javax.net.ssl.keyStore`, +`javax.net.ssl.trustStore`). When no extension-specific paths are configured, the JVM +defaults are used automatically. + +Minimal configuration (uses global JVM keystore and truststore): + +[source,properties] +---- +quarkus.morphium.ssl.enabled=true +quarkus.morphium.ssl.auth-mechanism=MONGODB-X509 +---- + +With extension-specific stores (overrides the JVM defaults for this connection only): + +[source,properties] +---- +quarkus.morphium.ssl.enabled=true +quarkus.morphium.ssl.auth-mechanism=MONGODB-X509 +quarkus.morphium.ssl.keystore-path=/etc/certs/client-keystore.p12 +quarkus.morphium.ssl.keystore-password=secret +quarkus.morphium.ssl.truststore-path=/etc/certs/mongo-truststore.jks +quarkus.morphium.ssl.truststore-password=changeit +---- + +The MongoDB username is extracted automatically from the client certificate's subject DN. +To override it explicitly: + +[source,properties] +---- +quarkus.morphium.ssl.x509-username=CN=myUser,OU=myUnit,O=myOrg,C=DE +---- + +When `x509-username` is set, the extension configures `$external` as the auth database and +clears the password (X.509 does not use password-based auth). + +=== MongoDB Atlas with TLS + +Atlas clusters use TLS by default with certificates signed by well-known CAs. Typically only +`ssl.enabled=true` is needed: + +[source,properties] +---- +quarkus.morphium.atlas-url=mongodb+srv://user:pass@cluster.mongodb.net/ +quarkus.morphium.ssl.enabled=true +---- + +=== Self-Signed Certificates (Development Only) + +For development environments with self-signed server certificates: + +[source,properties] +---- +quarkus.morphium.ssl.enabled=true +quarkus.morphium.ssl.invalid-hostname-allowed=true +quarkus.morphium.ssl.truststore-path=/etc/certs/dev-truststore.jks +quarkus.morphium.ssl.truststore-password=changeit +---- + +[WARNING] +==== +Never enable `invalid-hostname-allowed` in production. It disables hostname verification +and exposes the connection to man-in-the-middle attacks. +==== + +For the complete list of SSL/TLS properties see +xref:configuration.adoc[Configuration Reference]. + +== MongoDB Atlas SRV + +The `atlas-url` property accepts `mongodb+srv://` connection strings. When set, it overrides +the `hosts` property. + +[source,properties] +---- +quarkus.morphium.atlas-url=mongodb+srv://user:pass@cluster.mongodb.net/ +quarkus.morphium.database=my-database +---- + +Morphium resolves SRV records using a pure-Java `DnsSrvResolver` — no JNDI +`InitialDirContext` is used. This works reliably in GraalVM native images and restrictive +container environments where JNDI may not be available. + +== Blocking Call Detector + +The extension automatically detects Morphium write operations (store, delete, update) that +are called from a Vert.x I/O event-loop thread. Blocking the event loop causes request +timeouts and health-check failures. + +=== What It Detects + +The detector registers a `MorphiumStorageListener` at application startup. It monitors: + +* `preStore` — before `morphium.store()` +* `preRemove` — before `morphium.delete()` +* `preUpdate` — before `morphium.set()`, `morphium.inc()`, etc. + +When any of these are called from a thread named `vert.x-eventloop-thread-*`, a WARN log is +emitted: + +[source] +---- +[Morphium] Blocking write operation called from Vert.x I/O thread 'vert.x-eventloop-thread-0'. +This blocks the event loop and can cause request timeouts and health-check failures. +Fix: Add @RunOnVirtualThread (recommended) or @Blocking to your JAX-RS method. +---- + +Warnings are throttled to at most one every 30 seconds to avoid log flooding. + +=== Fix + +Annotate the offending JAX-RS / REST method: + +[source,java] +---- +import io.smallrye.common.annotation.RunOnVirtualThread; + +@GET +@Path("/products") +@RunOnVirtualThread // preferred — uses virtual threads +public List listProducts() { + return morphium.createQueryFor(ProductEntity.class).asList(); +} +---- + +Alternatively, use `@Blocking` to run on a worker thread: + +[source,java] +---- +import io.smallrye.common.annotation.Blocking; + +@GET +@Path("/products") +@Blocking +public List listProducts() { + return morphium.createQueryFor(ProductEntity.class).asList(); +} +---- + +== GraalVM Native Image + +The extension fully supports GraalVM native compilation. + +=== Automatic Reflection Registration + +At build time, the Quarkus deployment processor scans the classpath using ClassGraph and +registers every class annotated with `@Entity` or `@Embedded` for reflection. This includes: + +* Constructors (for `newInstance()`) +* Methods (for getter/setter access) +* Fields (for direct field access) + +No manual `reflect-config.json` entries are needed. + +=== Fallback + +If the ClassGraph scan fails (logged as a WARN at build time), you can add entries manually: + +[source,json] +---- +[ + { + "name": "com.example.ProductEntity", + "allDeclaredConstructors": true, + "allDeclaredMethods": true, + "allDeclaredFields": true + } +] +---- + +Place this file at `src/main/resources/META-INF/native-image/reflect-config.json`. + +=== Building a Native Image + +[source,bash] +---- +mvn package -Dnative +---- + +Or using a container build (no local GraalVM installation needed): + +[source,bash] +---- +mvn package -Dnative -Dquarkus.native.container-build=true +---- + +== Morphium Core Documentation + +The Quarkus extension wraps link:{morphium-github-url}[Morphium], which provides many +features beyond what is covered here: + +* *Fluent Query API* — `morphium.createQueryFor(T.class).f("field").eq(value)` +* *Aggregation Pipeline* — type-safe aggregation stage builder +* *MongoDB Messaging* — MongoDB-backed message queue +* *Change Streams* — real-time document change notifications +* *Field-Level Encryption* — transparent encryption of sensitive fields +* *JCache Integration* — JSR-107 compliant caching + +For full details see the link:{morphium-docs-url}[Morphium documentation] and the +link:{showcase-github-url}[quarkus-morphium-showcase] demo application. diff --git a/quarkus-morphium/docs/modules/ROOT/pages/configuration.adoc b/quarkus-morphium/docs/modules/ROOT/pages/configuration.adoc new file mode 100644 index 000000000..9c15bfd33 --- /dev/null +++ b/quarkus-morphium/docs/modules/ROOT/pages/configuration.adoc @@ -0,0 +1,255 @@ += Configuration Reference + +include::./includes/attributes.adoc[] + +All configuration properties live under the `quarkus.morphium.*` prefix in +`application.properties`. This page documents every available property. + +== Core Properties + +[cols="3,1,4",options="header"] +|=== +| Property | Default | Description + +| `quarkus.morphium.database` +| _(required)_ +| MongoDB database name. + +| `quarkus.morphium.hosts` +| `localhost:27017` +| Comma-separated `host:port` list. Overridden by `atlas-url` when set. + +| `quarkus.morphium.username` +| – +| MongoDB username (optional). + +| `quarkus.morphium.password` +| – +| MongoDB password (optional). + +| `quarkus.morphium.auth-database` +| `admin` +| Authentication database for SCRAM credentials. + +| `quarkus.morphium.atlas-url` +| – +| MongoDB Atlas SRV connection string (`mongodb+srv://...`). When set, overrides `hosts`. + +| `quarkus.morphium.read-preference` +| `primary` +| Read preference: `primary`, `primaryPreferred`, `secondary`, `secondaryPreferred`, `nearest`. + +| `quarkus.morphium.index-check` +| `create-on-startup` +| Index creation strategy: `create-on-startup` (create missing indexes when Morphium connects), `warn-on-startup` (log a warning, don't create -- not supported in native images, silently downgraded to `no-check` there), `create-on-write-new-col` (create lazily when writing to a new collection), `no-check` (disable all index management). + +| `quarkus.morphium.max-connections` +| `250` +| Maximum number of connections in the pool. + +| `quarkus.morphium.max-wait-time` +| `2000` +| Maximum time in milliseconds for low-level operations: waiting for a connection from the pool, driver-level timeouts, and change streams. Does not affect query execution -- use `default-query-timeout-ms` for that. + +| `quarkus.morphium.default-query-timeout-ms` +| `0` +| Default server-side time limit (`maxTimeMS`) in milliseconds for queries with no per-query timeout set. `0` (the default) disables the server-side limit entirely (Morphium sets `noCursorTimeout` instead). + +| `quarkus.morphium.replica-set-name` +| – +| MongoDB replica set name. Required for `@MorphiumTransactional` and change streams against a self-managed replica set. Dev Services sets this automatically when `quarkus.morphium.devservices.replica-set=true`. + +| `quarkus.morphium.connect-retries` +| `5` +| Number of connection attempts before giving up (minimum `1`). Useful in CI environments (Docker-in-Docker) where the replica set primary may not be immediately reachable after the container starts. + +| `quarkus.morphium.driver-name` +| `PooledDriver` +| Morphium driver implementation. Use `InMemDriver` for tests (no MongoDB required). +|=== + +== Cache Properties + +[cols="3,1,4",options="header"] +|=== +| Property | Default | Description + +| `quarkus.morphium.cache.read-cache-enabled` +| `true` +| Enable query-result caching for `@Cache`-annotated entities. + +| `quarkus.morphium.cache.global-valid-time` +| `60000` +| Global cache TTL in milliseconds. +|=== + +== LocalDateTime Storage + +[cols="3,1,4",options="header"] +|=== +| Property | Default | Description + +| `quarkus.morphium.local-date-time.use-bson-date` +| `true` +| Store `LocalDateTime` as BSON `ISODate`. Set to `false` only for backward compatibility with data written by Morphium {lt}= 6.1. +|=== + +.Format comparison +[cols="2,1,1",options="header"] +|=== +| | BSON `ISODate` (`true`) | Legacy Map (`false`) + +| New projects +| *recommended* +| – + +| Compatible with Morphia-written data +| yes +| no + +| Native date queries (`$gt`, `$lt`, sort) +| yes +| no + +| Readable in Atlas UI / mongosh +| yes +| no +|=== + +== SSL / TLS Properties + +These properties configure encrypted connections and X.509 client-certificate authentication. +See xref:advanced.adoc#ssl-tls[Advanced Topics: SSL/TLS] for usage examples. + +[cols="3,1,4",options="header"] +|=== +| Property | Default | Description + +| `quarkus.morphium.ssl.enabled` +| `false` +| Enable TLS for the MongoDB connection. + +| `quarkus.morphium.ssl.auth-mechanism` +| – +| Authentication mechanism. Leave unset for SCRAM-SHA-256 (default). Set to `MONGODB-X509` for X.509 client-certificate auth. + +| `quarkus.morphium.ssl.keystore-path` +| – +| Path to the keystore file (JKS or PKCS12) containing the client certificate for X.509 / mutual TLS. Falls back to the JVM default keystore (`javax.net.ssl.keyStore`) when absent. + +| `quarkus.morphium.ssl.keystore-password` +| – +| Password for the keystore. + +| `quarkus.morphium.ssl.truststore-path` +| – +| Path to the truststore file for validating the MongoDB server certificate. Falls back to the JVM default truststore when absent. + +| `quarkus.morphium.ssl.truststore-password` +| – +| Password for the truststore. + +| `quarkus.morphium.ssl.invalid-hostname-allowed` +| `false` +| Allow invalid / self-signed hostnames in the server certificate. *Do not enable in production.* + +| `quarkus.morphium.ssl.x509-username` +| – +| Explicit X.509 subject DN to use as the MongoDB username. When absent, the subject DN is extracted automatically from the client certificate. + +| `quarkus.morphium.ssl.tls-configuration-name` +| – +| Name of a Quarkus TLS configuration (from `quarkus.tls..*`) to use for the MongoDB connection instead of explicit keystore/truststore paths; use the special value `` to explicitly select the unnamed default TLS configuration. When absent and no explicit `keystore-path` / `truststore-path` is configured, the extension automatically falls back to the default (unnamed) Quarkus TLS configuration if one is available -- the recommended setup for native images where the runtime script writes `quarkus.tls.key-store.p12.*` / `quarkus.tls.trust-store.p12.*` properties. +|=== + +== Dev Services Properties (Build Time) + +Dev Services configuration is resolved at *build time* and cannot be overridden at runtime. +See xref:dev-services.adoc[Dev Services] for details. + +[cols="3,1,4",options="header"] +|=== +| Property | Default | Description + +| `quarkus.morphium.devservices.enabled` +| `true` +| Enable automatic MongoDB container in dev / test mode. + +| `quarkus.morphium.devservices.image-name` +| `mongo:8` +| Docker image for the MongoDB container. + +| `quarkus.morphium.devservices.database-name` +| `morphium-dev` +| Database name injected as `quarkus.morphium.database`. + +| `quarkus.morphium.devservices.replica-set` +| `true` +| Start MongoDB as a single-node replica set. Enables multi-document transactions, change streams, and `@MorphiumTransactional`. +|=== + +== Health Check Properties (Build Time) + +[cols="3,1,4",options="header"] +|=== +| Property | Default | Description + +| `quarkus.morphium.health.enabled` +| `true` +| Enable Morphium health checks (liveness, readiness, startup) via SmallRye Health. Health endpoints are available by default when the extension is present. +|=== + +== Migration Properties + +NOTE: `@Execution` methods must be idempotent -- the changelog entry marking a change unit as executed is written only after the method returns successfully, so a crash between the method completing and that write causes it to run again on the next start. See the `@Execution` Javadoc for details. + +IMPORTANT: The migration lock is renewed both **between** change units (after every executed migration) **and, while a single change unit is still running, by an in-flight heartbeat thread** that periodically extends the lock's TTL. This closes the gap where one change unit alone (e.g. an index build on a large collection) runs longer than `lock-ttl-seconds`: without the heartbeat, another instance could atomically take over the lock mid-unit and start running that *same* still-in-progress change unit concurrently. If a heartbeat tick ever detects that the lock has genuinely been taken over by another process (e.g. because the heartbeat itself was starved for longer than the TTL, or the owning process's clock drifted -- see the `lock-ttl-seconds` clock-skew note below), the migration run aborts with an explicit exception instead of silently continuing to write changelog entries concurrently with the new owner. + +[cols="3,1,4",options="header"] +|=== +| Property | Default | Description + +| `quarkus.morphium.migration.migrate-at-start` +| `false` +| Whether to run pending migrations automatically when the application starts. Migrations must be triggered explicitly (via `MorphiumMigrationRunner`) unless enabled. + +| `quarkus.morphium.migration.change-log-collection` +| `morphiumChangeLog` +| MongoDB collection that tracks executed migrations. + +| `quarkus.morphium.migration.lock-collection` +| `morphiumMigrationLock` +| MongoDB collection used for the distributed migration lock. + +| `quarkus.morphium.migration.lock-ttl-seconds` +| `60` +| Time-to-live in seconds for the migration lock. Must be greater than `0`. The lock is renewed both between change units and, via an in-flight heartbeat thread, while a single change unit is still running (see the IMPORTANT note above) -- so this mainly needs to comfortably exceed the heartbeat's own renewal interval, not the runtime of any single change unit or the whole migration run. Computed from each instance's local clock, not the MongoDB server's -- keep replica clocks synchronized (NTP/chrony) and set this generously above the expected clock drift between instances. + +| `quarkus.morphium.migration.lock-wait-seconds` +| `0` +| Maximum time in seconds to wait for the migration lock if another instance already holds it, polling every second, before giving up and failing startup. `0` (the default) fails immediately; set this above `0` in a multi-replica rolling deployment so replicas wait for an in-progress migration run instead of crash-looping. +|=== + +== Environment Variable Overrides + +SmallRye Config automatically maps property names to environment variables. Replace dots with +underscores and use upper case: + +[source,bash] +---- +export QUARKUS_MORPHIUM_DATABASE=production-db +export QUARKUS_MORPHIUM_HOSTS=mongo1:27017,mongo2:27017 +export QUARKUS_MORPHIUM_USERNAME=admin +export QUARKUS_MORPHIUM_PASSWORD=secret +export QUARKUS_MORPHIUM_SSL_ENABLED=true +---- + +== Configuration Precedence + +SmallRye Config resolves values in this order (highest priority first): + +1. System properties (`-Dquarkus.morphium.database=...`) +2. Environment variables (`QUARKUS_MORPHIUM_DATABASE=...`) +3. `.env` file in the project root +4. `application.properties` (profile-specific: `%dev.`, `%test.`, `%prod.`) +5. Default values defined in the extension diff --git a/quarkus-morphium/docs/modules/ROOT/pages/dev-services.adoc b/quarkus-morphium/docs/modules/ROOT/pages/dev-services.adoc new file mode 100644 index 000000000..8a3d5246f --- /dev/null +++ b/quarkus-morphium/docs/modules/ROOT/pages/dev-services.adoc @@ -0,0 +1,109 @@ += Dev Services + +include::./includes/attributes.adoc[] + +In *dev* (`quarkus dev`) and *test* mode the extension automatically starts a MongoDB Docker +container when `quarkus.morphium.hosts` is not explicitly configured. No additional setup is +needed. + +== How It Works + +1. At build time, the extension checks whether `quarkus.morphium.hosts` is set. +2. If not set and Dev Services are enabled, a MongoDB container is started via + link:{testcontainers-url}[Testcontainers]. +3. The container's mapped port and database name are injected as + `quarkus.morphium.hosts` and `quarkus.morphium.database`. +4. The container is reused across live reloads — it is *not* restarted when you change code. +5. On JVM shutdown (Ctrl-C or test runner exit), the container is stopped automatically. + +== Configuration + +[cols="3,1,4",options="header"] +|=== +| Property | Default | Description + +| `quarkus.morphium.devservices.enabled` +| `true` +| Set to `false` to disable the automatic container. + +| `quarkus.morphium.devservices.image-name` +| `mongo:8` +| Docker image to use (e.g. `mongo:7`, `mongo:6`). + +| `quarkus.morphium.devservices.database-name` +| `morphium-dev` +| Database name injected into `quarkus.morphium.database`. + +| `quarkus.morphium.devservices.replica-set` +| `true` +| Start MongoDB as a single-node replica set. Enables multi-document transactions, change streams, and other oplog-dependent features. +|=== + +== Replica-Set Mode + +By default, Dev Services starts MongoDB as a single-node replica set via Testcontainers' +`MongoDBContainer.withReplicaSet()`. This enables multi-document transactions, change streams, +and `@MorphiumTransactional` out of the box. + +The extension uses Testcontainers' `MongoDBContainer` which automatically: + +* Starts MongoDB with `--replSet` +* Executes `rs.initiate()` +* Waits for the node to become PRIMARY + +This gives you a fully functional replica set in a single container. + +== Dev UI Card + +In dev mode (`quarkus dev`), the extension registers a card in the Quarkus Dev UI at +`/q/dev-ui/`. The card queries the running Morphium instance at runtime via JsonRPC and +displays: + +[cols="1,3",options="header"] +|=== +| Field | Description + +| Hosts +| The `host:port` list from the cluster configuration, or the Atlas/SRV URL when applicable. + +| Database +| The configured database name. + +| Mode +| `Standalone` or `Replica Set (transactions enabled)` — detected at runtime via the MongoDB hello handshake. + +| Driver +| The active Morphium driver implementation (e.g. `PooledDriver`, `InMemDriver`). + +| Status +| `Connected` or `Disconnected` — reflects the actual runtime connection state. +|=== + +== Hot-Reload Behavior + +When you save a file in dev mode: + +* The MongoDB container *survives* the live reload — it is not restarted. +* The Morphium `ObjectMapperImpl` entity class cache is cleared so that ClassGraph re-scans + the classpath with the new `QuarkusClassLoader`. Without this, stale class references from + the previous class loader would cause entity mapping failures. + +== Disabling Dev Services + +To use an external MongoDB instead of the automatic container: + +[source,properties] +---- +quarkus.morphium.devservices.enabled=false +quarkus.morphium.hosts=my-mongo:27017 +quarkus.morphium.database=mydb +---- + +Alternatively, simply setting `quarkus.morphium.hosts` is sufficient — Dev Services are +automatically skipped when hosts are explicitly configured. + +== Fallback Behavior + +If the container fails to start (e.g. Docker not available), the extension logs a WARN and +falls back to the configured `quarkus.morphium.hosts` (if any). The application will not +fail to start — but it will fail at runtime if no MongoDB is reachable. diff --git a/quarkus-morphium/docs/modules/ROOT/pages/entities.adoc b/quarkus-morphium/docs/modules/ROOT/pages/entities.adoc new file mode 100644 index 000000000..91f82f646 --- /dev/null +++ b/quarkus-morphium/docs/modules/ROOT/pages/entities.adoc @@ -0,0 +1,281 @@ += Entities & Annotations + +include::./includes/attributes.adoc[] + +Morphium maps Java POJOs to MongoDB documents using annotations. This page covers all +annotations supported by the Quarkus extension. + +== @Entity + +Marks a class as a top-level MongoDB document stored in its own collection. + +[source,java] +---- +import de.caluga.morphium.annotations.Entity; + +@Entity(collectionName = "products") +public class ProductEntity { + // ... +} +---- + +The `collectionName` parameter specifies the MongoDB collection name. If omitted, Morphium +derives it from the class name (lowercased). + +== @Embedded + +Marks a class as an embedded (sub-)document that is stored inside another entity's document, +not in its own collection. + +[source,java] +---- +import de.caluga.morphium.annotations.Embedded; + +@Embedded +public class AddressEmbedded { + @Property(fieldName = "street") private String street; + @Property(fieldName = "city") private String city; + // getters / setters +} +---- + +Use embedded documents for data that doesn't need its own collection and is always loaded +together with the parent entity. + +== @Id + +Marks the primary key field. Morphium supports `MorphiumId` (similar to MongoDB `ObjectId`) +and `String` as ID types. + +[source,java] +---- +import de.caluga.morphium.annotations.Id; + +@Id +private String id; +---- + +Morphium automatically generates the ID on `store()` if the field is `null`. + +== @Property + +Maps a Java field to a specific MongoDB document field name. + +[source,java] +---- +import de.caluga.morphium.annotations.Property; + +@Property(fieldName = "display_name") +private String name; +---- + +Without `@Property`, Morphium uses the Java field name as-is. + +== @Version — Optimistic Locking + +Enables optimistic locking. Morphium increments the version on every `store()` and throws an +exception if the document was modified concurrently. + +[source,java] +---- +import de.caluga.morphium.annotations.Version; + +@Version +@Property(fieldName = "version") +private long version; +---- + +For more details see the link:{morphium-docs-url}[Morphium core documentation]. + +== @AutoSequence + +Generates automatic, sequential numeric IDs using a server-side sequence. + +[source,java] +---- +import de.caluga.morphium.annotations.AutoSequence; + +@AutoSequence +@Id +private long id; +---- + +For details on sequence configuration see the link:{morphium-docs-url}[Morphium core documentation]. + +== Lifecycle Annotations + +Morphium supports lifecycle callbacks via annotations. Annotate the entity class with +`@Lifecycle` and individual methods with the appropriate callback annotation. + +[source,java] +---- +import de.caluga.morphium.annotations.lifecycle.*; + +@Entity(collectionName = "products") +@Lifecycle +public class ProductEntity { + + @PreStore + public void beforeSave() { + // called before each store() operation + } + + @PostStore + public void afterSave() { + // called after a successful store() + } +} +---- + +.Available lifecycle annotations +[cols="1,3",options="header"] +|=== +| Annotation | When it fires + +| `@PreStore` +| Before `store()` — validate or set defaults + +| `@PostStore` +| After a successful `store()` + +| `@PreRemove` +| Before `delete()` + +| `@PostRemove` +| After a successful `delete()` + +| `@PostLoad` +| After loading a document from MongoDB +|=== + +== @Cache + +Enables query-result caching for an entity type. Cached queries are served from memory until +the cache TTL expires or the cache is invalidated by a write operation. + +[source,java] +---- +import de.caluga.morphium.annotations.caching.Cache; + +@Cache(maxEntries = 1000, clearOnWrite = true) +@Entity(collectionName = "products") +public class ProductEntity { + // ... +} +---- + +Cache behavior is controlled globally via `quarkus.morphium.cache.*` properties (see +xref:configuration.adoc[Configuration Reference]) and per-entity via `@Cache` attributes. +For advanced caching patterns see the link:{morphium-docs-url}[Morphium core documentation]. + +== @Reference + +Stores a link to another entity in a separate collection instead of embedding it inline. Morphium +persists only the referenced entity's `_id` in the parent document and resolves it on load. + +[source,java] +---- +import de.caluga.morphium.annotations.Reference; + +@Entity +public class BlogPost { + @Id private MorphiumId id; + + @Reference + private Author author; + + @Reference(lazyLoading = true) + private Author reviewer; + + @Reference(cascadeDelete = true) + private List items; + + @Reference(orphanRemoval = true) + private List tags; +} +---- + +.@Reference attributes +[cols="2,1,5",options="header"] +|=== +| Attribute | Default | Description + +| `automaticStore` +| `true` +| When `true`, Morphium automatically persists referenced objects that don't yet have an ID when +the parent is stored. Set to `false` to control persistence order manually. + +| `lazyLoading` +| `false` +| When `true`, the referenced entity is not loaded from the database until a method on the proxy is +called. Useful for rarely-accessed references or to break bidirectional deserialization cycles. + +| `cascadeDelete` +| `false` +| When `true`, deleting the parent entity also deletes the referenced entities. Only applies to +entity-based `delete(Object)` calls, not query-based deletes. Circular cascade references are +detected and do not cause infinite loops. + +| `orphanRemoval` +| `false` +| When `true`, updating the parent entity automatically deletes referenced entities that are no +longer referenced. Only triggers on updates (entities with an existing ID), not on inserts. + +| `fieldName` +| `.` +| Override the MongoDB field name for the reference. Defaults to the Java field name. + +| `targetCollection` +| `.` +| Override the target collection for the referenced entity. Defaults to the entity's own collection. +|=== + +=== automaticStore (default: true) + +With the default `automaticStore = true`, you do not need to store referenced entities before +storing the parent. Morphium handles this automatically: + +[source,java] +---- +Author author = new Author(); +author.setName("Jane"); +// author has no ID yet — Morphium will store it automatically + +BlogPost post = new BlogPost(); +post.setAuthor(author); +morphium.store(post); // author is auto-stored first, then post references author's new ID +---- + +Set `automaticStore = false` when you want to control persistence order manually: + +[source,java] +---- +@Reference(automaticStore = false) +private Author author; + +// Must store author explicitly first: +morphium.store(author); +post.setAuthor(author); +morphium.store(post); +---- + +=== Circular references + +Morphium includes cycle detection for circular `@Reference` chains (e.g., A → B → A). If a cycle +is detected during serialization, objects with IDs return a minimal `{_id: ...}` document; objects +without IDs throw `IllegalStateException` with a clear error message. + +For bidirectional references, use `lazyLoading = true` on at least one side to prevent +deserialization cycles. + +For more details see the link:{morphium-docs-url}[Morphium core documentation]. + +== GraalVM Native Image + +All classes annotated with `@Entity` or `@Embedded` are automatically registered for GraalVM +reflection at build time. The Quarkus deployment processor uses ClassGraph to scan the +classpath and registers constructors, methods, and fields for each annotated class. + +No manual `reflect-config.json` entries are needed. If the classpath scan fails (logged as a +WARN at build time), you can add entries manually via standard GraalVM reflection +configuration. diff --git a/quarkus-morphium/docs/modules/ROOT/pages/getting-started.adoc b/quarkus-morphium/docs/modules/ROOT/pages/getting-started.adoc new file mode 100644 index 000000000..b0baf128a --- /dev/null +++ b/quarkus-morphium/docs/modules/ROOT/pages/getting-started.adoc @@ -0,0 +1,185 @@ += Getting Started + +include::./includes/attributes.adoc[] + +This guide walks you through adding the Quarkus Morphium extension to a project, configuring a +MongoDB connection, defining your first entity, and performing basic CRUD operations. + +== Prerequisites + +* JDK 21+ +* Apache Maven 3.9+ +* A running MongoDB instance (or just use <> — no setup needed) + +== Installation + +Add the extension to your `pom.xml`: + +[source,xml,subs=attributes+] +---- + + {quarkus-morphium-groupid} + quarkus-morphium + {quarkus-morphium-version} + +---- + +[NOTE] +==== +`quarkus-morphium` is an **optional module of Morphium**. It shares Morphium's +Maven reactor, groupId, and release version, but the Morphium core +(`de.caluga:morphium`) does not depend on it — adding core Morphium alone does not +pull in Quarkus or any of its APIs. This extension is what you add explicitly when +you want Morphium wired into Quarkus via CDI. +==== + +To build the extension from source instead of using a released artifact, run +`mvn -pl quarkus-morphium -am verify` from the root of the +link:{morphium-github-url}[Morphium repository] (`-am` also builds `morphium` core +first if it is not already up to date in the reactor). + +== Minimal Configuration + +Create `src/main/resources/application.properties` with a single required property: + +[source,properties] +---- +quarkus.morphium.database=my-database +---- + +That's it. In dev and test mode, Dev Services automatically starts a MongoDB container — no +Docker configuration needed. For all configuration options see +xref:configuration.adoc[Configuration Reference]. + +== Define an Entity + +[source,java] +---- +import de.caluga.morphium.annotations.*; +import de.caluga.morphium.annotations.lifecycle.*; +import java.time.Instant; + +@Entity(collectionName = "products") +@Lifecycle +public class ProductEntity { + + @Id + private String id; + + @Property(fieldName = "name") + private String name; + + @Property(fieldName = "price") + private double price; + + @Version + @Property(fieldName = "version") + private long version; + + @Property(fieldName = "created_at") + private Instant createdAt; + + @PreStore + public void onStore() { + if (createdAt == null) createdAt = Instant.now(); + } + + // getters / setters +} +---- + +Every class annotated with `@Entity` or `@Embedded` is automatically registered for GraalVM +reflection at build time — no manual `reflect-config.json` required. + +For a complete guide to annotations see xref:entities.adoc[Entities & Annotations]. + +== Create a Repository (Jakarta Data) + +The recommended approach is to use Jakarta Data `@Repository` interfaces. The extension +generates the implementation at build time: + +[source,java] +---- +import de.caluga.morphium.driver.MorphiumId; +import jakarta.data.repository.CrudRepository; +import jakarta.data.repository.OrderBy; +import jakarta.data.repository.Repository; +import java.util.List; + +@Repository +public interface ProductRepository extends CrudRepository { + + List findByName(String name); + + @OrderBy("price") + List findByPriceGreaterThan(double minPrice); +} +---- + +[source,java] +---- +@ApplicationScoped +public class ProductService { + + @Inject ProductRepository products; + + public ProductEntity save(ProductEntity product) { + return products.save(product); + } + + public List findByName(String name) { + return products.findByName(name); + } +} +---- + +For the full Jakarta Data guide (query derivation, `@Find`/`@By`, JDQL, pagination, +`MorphiumRepository`) see xref:jakarta-data.adoc[Jakarta Data 1.0]. + +== Imperative API (Inject Morphium) + +For aggregation pipelines, atomic updates, and other operations beyond Jakarta Data, +inject `Morphium` directly: + +[source,java] +---- +import de.caluga.morphium.Morphium; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import java.util.List; + +@ApplicationScoped +public class ProductAnalytics { + + @Inject + Morphium morphium; + + public List findAll() { + return morphium.createQueryFor(ProductEntity.class).asList(); + } +} +---- + +The `Morphium` instance is a CDI `@ApplicationScoped` bean — the extension manages its full +lifecycle (connection setup, shutdown, hot-reload cache clearing). + +TIP: Both approaches work together. Use `MorphiumRepository` for the best of both worlds — +standard CRUD via Jakarta Data plus `morphium()` and `query()` for the escape hatch. + +[[dev-services]] +== Dev Services + +When you run `quarkus dev` or execute tests, the extension automatically starts a MongoDB +Docker container. No manual Docker setup is needed. See xref:dev-services.adoc[Dev Services] +for details. + +== Next Steps + +* xref:jakarta-data.adoc[Jakarta Data 1.0] — query derivation, `@Find`/`@By`, JDQL, pagination, `MorphiumRepository` +* xref:configuration.adoc[Configuration Reference] — all `quarkus.morphium.*` properties +* xref:entities.adoc[Entities & Annotations] — `@Entity`, `@Embedded`, `@Id`, `@Version`, lifecycle hooks +* xref:transactions.adoc[Transactions] — declarative `@MorphiumTransactional` +* xref:dev-services.adoc[Dev Services] — automatic MongoDB container, replica-set mode +* xref:health-checks.adoc[Health Checks] — MicroProfile Health probes +* xref:testing.adoc[Testing] — Dev Services vs. InMemDriver strategies +* xref:advanced.adoc[Advanced Topics] — SSL/TLS, Atlas SRV, GraalVM native diff --git a/quarkus-morphium/docs/modules/ROOT/pages/health-checks.adoc b/quarkus-morphium/docs/modules/ROOT/pages/health-checks.adoc new file mode 100644 index 000000000..83dee4c66 --- /dev/null +++ b/quarkus-morphium/docs/modules/ROOT/pages/health-checks.adoc @@ -0,0 +1,159 @@ += Health Checks + +include::./includes/attributes.adoc[] + +The extension automatically registers three MicroProfile Health probes with the SmallRye +Health subsystem. These probes integrate with Kubernetes liveness, readiness, and startup +probes out of the box. + +NOTE: `quarkus-smallrye-health` is an *optional* dependency of this extension. +Add `io.quarkus:quarkus-smallrye-health` to your project's dependencies to enable +health endpoints — without it, no health probes are registered. + +== Probes Overview + +[cols="1,2,2,2",options="header"] +|=== +| Probe | Endpoint | Condition | Kubernetes Behavior + +| Liveness +| `/q/health/live` +| Morphium bean is usable (does not check MongoDB connectivity) +| DOWN triggers pod *restart* + +| Readiness +| `/q/health/ready` +| Driver is connected +| DOWN removes pod from *service endpoints* + +| Startup +| `/q/health/started` +| Initial connection established +| DOWN *defers* liveness and readiness probes +|=== + +== Liveness Check + +Reports UP unless the `Morphium` bean itself is unusable (e.g. a misconfiguration prevents +even constructing the driver). Does *not* report DOWN on a lost MongoDB connection. + +*Metadata:* + +* `database` — the configured database name +* `driver` — the driver class name (e.g. `PooledDriver`) + +A DOWN liveness probe causes Kubernetes to restart the pod, which does not fix an unreachable +MongoDB server -- it would restart every replica in the deployment simultaneously (they all lose +connectivity to the same outage at once) and take the application fully offline until MongoDB +recovers. MongoDB connectivity is therefore intentionally the readiness probe's concern instead, +which correctly removes a pod from the Service's endpoint list without killing it, and +automatically re-adds it once the connection recovers. + +== Readiness Check + +Reports UP when the Morphium driver is connected. Pool statistics are included as +*informational metadata* but do not affect the UP/DOWN status. + +*Metadata:* + +* `database` — the configured database name +* `connectionsInUse` — current number of active connections +* `connectionsInPool` — total connections in the pool +* `threadsWaiting` — threads waiting for a connection +* `errors` — total error count +* `host:` — per-host connection count + +Pool saturation during bulk operations is normal and does not affect readiness. This is +consistent with how other Quarkus MongoDB extensions handle readiness (ping only). + +If pool statistics cannot be collected (e.g. during heavy load), the probe still returns +UP with a `statsUnavailable` metadata entry. + +== Startup Check + +Reports DOWN until the initial MongoDB connection has been established. + +*Metadata:* + +* `database` — the configured database name +* `connectionsOpened` — total number of connections opened since startup + +A DOWN startup probe causes Kubernetes to defer liveness and readiness checks, giving the +application time to establish its first connection. + +== JSON Response Example + +[source,json] +---- +{ + "status": "UP", + "checks": [ + { + "name": "Morphium liveness check", + "status": "UP", + "data": { + "database": "my-database", + "driver": "PooledDriver" + } + }, + { + "name": "Morphium readiness check", + "status": "UP", + "data": { + "database": "my-database", + "connectionsInUse": 2, + "connectionsInPool": 10, + "threadsWaiting": 0, + "errors": 0, + "host:localhost:27017": 10 + } + }, + { + "name": "Morphium startup check", + "status": "UP", + "data": { + "database": "my-database", + "connectionsOpened": 10 + } + } + ] +} +---- + +== Kubernetes Probe Mapping + +[source,yaml] +---- +livenessProbe: + httpGet: + path: /q/health/live + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + +readinessProbe: + httpGet: + path: /q/health/ready + port: 8080 + initialDelaySeconds: 5 + periodSeconds: 10 + +startupProbe: + httpGet: + path: /q/health/started + port: 8080 + initialDelaySeconds: 3 + periodSeconds: 5 + failureThreshold: 12 +---- + +== Disabling Health Checks + +To suppress all Morphium health checks: + +[source,properties] +---- +quarkus.morphium.health.enabled=false +---- + +This is a *build-time* property — changes require a rebuild. diff --git a/quarkus-morphium/docs/modules/ROOT/pages/includes/attributes.adoc b/quarkus-morphium/docs/modules/ROOT/pages/includes/attributes.adoc new file mode 100644 index 000000000..cb7cd51de --- /dev/null +++ b/quarkus-morphium/docs/modules/ROOT/pages/includes/attributes.adoc @@ -0,0 +1,13 @@ +:quarkus-morphium-groupid: de.caluga +:quarkus-morphium-version: 6.3.0-SNAPSHOT +:quarkus-version: 3.32.3 +:morphium-version: 6.3.0-SNAPSHOT +:extension-status: preview + +:github-base-url: https://github.com/sboesebeck/morphium/tree/develop/quarkus-morphium +:morphium-github-url: https://github.com/sboesebeck/morphium +:morphium-docs-url: https://sboesebeck.github.io/morphium +:quarkus-guides-url: https://quarkus.io/guides +:showcase-github-url: https://github.com/Bardioc1977/quarkus-morphium-showcase +:quarkiverse-url: https://quarkiverse.github.io +:testcontainers-url: https://java.testcontainers.org diff --git a/quarkus-morphium/docs/modules/ROOT/pages/index.adoc b/quarkus-morphium/docs/modules/ROOT/pages/index.adoc new file mode 100644 index 000000000..6d256461f --- /dev/null +++ b/quarkus-morphium/docs/modules/ROOT/pages/index.adoc @@ -0,0 +1,95 @@ += Quarkus Morphium Extension + +include::./includes/attributes.adoc[] + +The Quarkus Morphium extension integrates link:{morphium-github-url}[Morphium], an actively +maintained MongoDB ORM for Java, into Quarkus via CDI — with full +link:https://jakarta.ee/specifications/data/1.0/[**Jakarta Data 1.0**] support. + +[NOTE] +==== +`quarkus-morphium` is an **optional module of Morphium** — it lives in the same Maven +reactor and is released in lockstep with Morphium core, but the core +(`de.caluga:morphium`) has no dependency on this extension or on Quarkus. Add this +module explicitly when you want Morphium available as a Quarkus CDI extension. +==== + +== Jakarta Data 1.0 — Declarative Repositories for MongoDB + +Define a `@Repository` interface, inject it, done. The implementation is generated at **build time** +via Gizmo bytecode generation — no runtime reflection, no proxies, GraalVM native-image compatible. + +[source,java] +---- +@Repository +public interface ProductRepository extends MorphiumRepository { + + List findByCategory(String category); + + @OrderBy("price") + List findByPriceBetween(double min, double max); +} +---- + +Supports: query derivation (`findBy`, `countBy`, `existsBy`, `deleteBy`), `@Find`/`@By`, +`@Query` with JDQL, `@OrderBy`, pagination (`Page`, `PageRequest`), sorting (`Sort`, `Order`), +and auto-generated `@StaticMetamodel` classes. + +All Morphium ORM features (`@Version`, `@CreationTime`, `@PreStore`, `@Cache`, `@Reference`) +work transparently through repositories. + +For the full guide see xref:jakarta-data.adoc[Jakarta Data 1.0]. + +== Features + +* *Jakarta Data 1.0* – `@Repository` interfaces with query derivation, `@Find`/`@By`, `@Query`/JDQL, pagination, `@StaticMetamodel` +* *MorphiumRepository* – provider-specific extension with `distinct()`, `morphium()`, `query()` escape hatch +* *Zero-boilerplate injection* – inject `Morphium` or any `@Repository` interface directly via `@Inject` +* *Declarative transactions* – `@MorphiumTransactional` for automatic commit / rollback with CDI lifecycle events +* *Type-safe configuration* – all settings live under the `quarkus.morphium.*` prefix in `application.properties` +* *Dev Services* – a MongoDB container is started automatically in dev and test mode; no manual Docker setup needed +* *Health checks* – MicroProfile liveness, readiness, and startup probes registered automatically +* *SSL/TLS & X.509* – encrypted connections and client-certificate authentication +* *GraalVM native ready* – all `@Entity` and `@Embedded` classes are registered for reflection at build time +* *Blocking call detection* – warns when Morphium writes are called from the Vert.x event loop +* *Dev UI card* – shows MongoDB connection info in the Quarkus Dev UI at `/q/dev-ui/` +* *Fast tests* – use the `InMemDriver` profile from `quarkus-morphium-testing` for instant, container-free tests + +== Documentation + +[cols="1,3"] +|=== +| xref:getting-started.adoc[Getting Started] +| Installation, minimal configuration, first entity, first query. + +| xref:jakarta-data.adoc[Jakarta Data 1.0] +| `@Repository`, `CrudRepository`, `MorphiumRepository`, query derivation, `@Find`/`@By`, `@Query`/JDQL, pagination, `@StaticMetamodel`. + +| xref:configuration.adoc[Configuration Reference] +| All `quarkus.morphium.*` properties including SSL/TLS, Dev Services, and health checks. + +| xref:entities.adoc[Entities & Annotations] +| `@Entity`, `@Embedded`, `@Id`, `@Version`, `@AutoSequence`, lifecycle annotations, `@Cache`. + +| xref:transactions.adoc[Transactions] +| Declarative `@MorphiumTransactional`, lifecycle events, replica-set requirement. + +| xref:dev-services.adoc[Dev Services] +| Automatic MongoDB container, replica-set mode, Dev UI card, hot-reload behavior. + +| xref:health-checks.adoc[Health Checks] +| MicroProfile liveness, readiness, and startup probes with pool metadata. + +| xref:testing.adoc[Testing] +| Dev Services vs. InMemDriver strategies, test isolation, mixing approaches. + +| xref:advanced.adoc[Advanced Topics] +| SSL/TLS, Atlas SRV, blocking call detector, GraalVM native image details. +|=== + +== Links + +* link:{morphium-github-url}[Morphium on GitHub] — the core ODM library +* link:{morphium-docs-url}[Morphium Documentation] — full API reference, messaging, aggregation +* link:{github-base-url}[quarkus-morphium on GitHub] — this extension's source code (`quarkus-morphium/` directory in the Morphium repository) +* link:{showcase-github-url}[quarkus-morphium-showcase] — demo application showing all features diff --git a/quarkus-morphium/docs/modules/ROOT/pages/jakarta-data.adoc b/quarkus-morphium/docs/modules/ROOT/pages/jakarta-data.adoc new file mode 100644 index 000000000..1f36201fe --- /dev/null +++ b/quarkus-morphium/docs/modules/ROOT/pages/jakarta-data.adoc @@ -0,0 +1,276 @@ += Jakarta Data 1.0 + +include::./includes/attributes.adoc[] + +The quarkus-morphium extension provides full link:https://jakarta.ee/specifications/data/1.0/[Jakarta Data 1.0] support +for MongoDB. Define a `@Repository` interface, inject it, done. The implementation is generated +at **Quarkus build time** via Gizmo bytecode generation — no runtime reflection, no proxies, +GraalVM native-image compatible. + +== Quick Example + +[source,java] +---- +@Repository +public interface ProductRepository extends CrudRepository { + + List findByCategory(String category); + + @OrderBy("price") + List findByPriceBetween(double min, double max); + + long countByCategory(String category); + + boolean existsByName(String name); +} +---- + +[source,java] +---- +@ApplicationScoped +public class ProductService { + + @Inject ProductRepository products; + + public Product create(String name, double price, String category) { + var product = new Product(); + product.setName(name); + product.setPrice(price); + product.setCategory(category); + return products.insert(product); + } +} +---- + +== Repository Hierarchy + +The extension supports the full Jakarta Data repository hierarchy: + +[cols="1,2"] +|=== +| `DataRepository` | Marker interface — no methods, used for custom repositories +| `BasicRepository` | `findById`, `findAll`, `save`, `saveAll`, `delete`, `deleteById`, `deleteAll` +| `CrudRepository` | Extends BasicRepository — adds `insert`, `insertAll`, `update`, `updateAll` +| `MorphiumRepository` | Extends CrudRepository — adds `distinct()`, `morphium()`, `query()` for Morphium-specific features +|=== + +== MorphiumRepository — The Escape Hatch + +`MorphiumRepository` is a provider-specific extension of `CrudRepository`. It provides +access to Morphium features that have no equivalent in Jakarta Data 1.0: + +[source,java] +---- +@Repository +public interface ProductRepository extends MorphiumRepository { + + List findByCategory(String category); +} +---- + +[source,java] +---- +// Distinct values for a field +List categories = products.distinct("category"); + +// Direct access to the Morphium API for aggregation, atomic updates, etc. +Morphium m = products.morphium(); +m.inc(product, "stock", 5); + +// Create a typed Morphium Query for complex conditions +Query q = products.query(); +q.f("price").gt(100).f("category").eq("electronics"); +List results = q.asList(); +---- + +All standard Jakarta Data features (CRUD, query derivation, `@Find`, `@Query`, pagination, sorting) +work exactly the same as with `CrudRepository`. + +== Query Derivation + +Define query methods by naming convention. The method name is parsed at build time and validated +against the entity's fields. + +[source,java] +---- +List findByName(String name); // WHERE name = ? +List findByPriceGreaterThan(double min); // WHERE price > ? +List findByPriceBetween(double min, double max); // WHERE price >= ? AND price <= ? +List findByNameLike(String pattern); // WHERE name LIKE ? +List findByActiveTrue(); // WHERE active = true +List findByTagNull(); // WHERE tag IS NULL +long countByCategory(String category); // COUNT WHERE category = ? +boolean existsByEmail(String email); // EXISTS WHERE email = ? +void deleteByStatus(String status); // DELETE WHERE status = ? +---- + +=== Supported Operators + +[cols="1,1,2"] +|=== +| Suffix | Morphium Equivalent | Example + +| `Equals` (default) | `.eq()` | `findByName(String)` +| `Not` | `.ne()` | `findByStatusNot(String)` +| `GreaterThan` | `.gt()` | `findByPriceGreaterThan(double)` +| `GreaterThanEqual` | `.gte()` | `findByPriceGreaterThanEqual(double)` +| `LessThan` | `.lt()` | `findByPriceLessThan(double)` +| `LessThanEqual` | `.lte()` | `findByPriceLessThanEqual(double)` +| `Between` | `.gte()` + `.lte()` | `findByPriceBetween(double, double)` +| `In` | `.in()` | `findByStatusIn(List)` +| `NotIn` | `.nin()` | `findByStatusNotIn(List)` +| `Like` | `.matches()` | `findByNameLike(String)` +| `StartsWith` | `.matches("^"+val)` | `findByNameStartsWith(String)` +| `EndsWith` | `.matches(val+"$")` | `findByNameEndsWith(String)` +| `Null` | `.notExists()` | `findByTagNull()` +| `NotNull` | `.exists()` | `findByTagNotNull()` +| `True` | `.eq(true)` | `findByActiveTrue()` +| `False` | `.eq(false)` | `findByActiveFalse()` +|=== + +Operators can be combined with `And` and `Or`: + +[source,java] +---- +List findByCategoryAndPriceGreaterThan(String cat, double min); +List findByNameOrTag(String name, String tag); +---- + +== @Find / @By — Explicit Field Binding + +Use `@Find` with `@By` parameter annotations for explicit field binding. This is useful +for embedded fields (dot notation) or when method naming doesn't map cleanly. + +[source,java] +---- +@Find +List findByCategory(@By("category.name") String categoryName); + +@Find +@OrderBy(value = "price", descending = true) +List topByCategory(@By("category.name") String name, Limit limit); + +@Find +List search(@By("category") String cat, + @By("price") double minPrice, + Sort sort); +---- + +NOTE: `@By`-bound parameters are always applied as equality conditions. Jakarta Data's +`@Is(Operator)` annotation for non-equality `@By` conditions (e.g. +`@By("price") @Is(GreaterThanEqual)`) requires Jakarta Data 1.1, which is not yet +finalized (latest available artifact is the `1.1.0-M3` milestone) — this module targets +the stable `jakarta.data-api:1.0.0`. Use query derivation +(`findByPriceGreaterThan(...)`) or `@Query` (JDQL) for non-equality conditions today. + +== @Query / JDQL — Jakarta Data Query Language + +For complex queries, use `@Query` with JDQL syntax: + +[source,java] +---- +@Query("WHERE name LIKE :pattern ORDER BY price ASC") +List searchByNameLike(@Param("pattern") String pattern); + +@Query("WHERE category = :cat AND price > :minPrice ORDER BY price") +List findExpensive(@Param("cat") String category, + @Param("minPrice") double minPrice); + +@Query("WHERE price >= :min AND price <= :max ORDER BY price ASC") +List queryByPriceRange(@Param("min") double min, @Param("max") double max); + +@Query("WHERE price >= :min") +long countByMinPrice(@Param("min") double minPrice); +---- + +JDQL supports: `WHERE`, `ORDER BY`, named parameters (`:param`), comparison operators +(`=`, `<>`, `>`, `<`, `>=`, `\<=`), `BETWEEN`, `IN`, `LIKE`, `IS NULL`, `IS NOT NULL`, `NOT`. + +== Pagination & Sorting + +[source,java] +---- +// Paginated query +Page findByCategory(String category, PageRequest pageRequest); + +// Dynamic sort via Order parameter +Page findAll(PageRequest pageRequest, Order order); + +// Static sort with @OrderBy +@OrderBy("price") +List findByCategory(String category); + +// Limit results +@Find +List findTop(@By("category") String cat, Limit limit); +---- + +[source,java] +---- +// Usage +Page page = products.findByCategory("electronics", + PageRequest.ofPage(1, 20, true)); + +page.content(); // List +page.totalElements(); // total count +page.totalPages(); // calculated from total and page size +page.hasNext(); // true if more pages exist +---- + +== @StaticMetamodel — Type-Safe Field References + +The extension auto-generates `@StaticMetamodel` classes at build time for every `@Entity`: + +[source,java] +---- +// Auto-generated: Product_.java +@StaticMetamodel(Product.class) +public class Product_ { + public static final String NAME = "name"; + public static volatile SortableAttribute name; + public static volatile SortableAttribute price; + public static volatile TextAttribute category; + // ... +} +---- + +Use them for type-safe sorting: + +[source,java] +---- +Order order = Order.by(Product_.price.asc(), Product_.name.asc()); +Page page = products.findAll(PageRequest.ofPage(1), order); +---- + +== Morphium ORM Features — Transparent Through Repositories + +All Morphium ORM annotations work transparently through Jakarta Data repositories because +the generated implementations delegate to `morphium.store()`, `morphium.findById()`, etc.: + +[cols="1,2"] +|=== +| Feature | How it works + +| `@Version` | Optimistic locking — Morphium checks and increments the version on every `save()`/`update()` +| `@CreationTime` / `@LastChange` | Automatically set on first store / every store +| `@PreStore` / `@PostStore` / `@PostLoad` | Lifecycle callbacks fired by Morphium on store/load +| `@Cache` / `@WriteBuffer` | Read cache and async write batching +| `@Reference` (lazy/eager) | Document references with optional `cascadeDelete` and `orphanRemoval` +| `@Index` | Index creation managed by Morphium on startup +|=== + +== When to Use Which + +[cols="1,1"] +|=== +| Use Jakarta Data for | Use Morphium API for + +| Standard CRUD (save, findById, delete) | Aggregation pipelines ($group, $project) +| Simple to medium queries (findBy, countBy) | Atomic field operations (inc, push, pull) +| Paginated results (Page, PageRequest) | Bulk updates ($set, $unset) +| JDQL queries (WHERE, ORDER BY, LIKE) | Change streams, messaging +| Testable interfaces (easy to mock) | Geospatial queries ($near, $geoWithin) +|=== + +TIP: Both approaches work together. Use `MorphiumRepository` for the best of both worlds — +Jakarta Data for standard operations, and `morphium()` / `query()` for the escape hatch. diff --git a/quarkus-morphium/docs/modules/ROOT/pages/testing.adoc b/quarkus-morphium/docs/modules/ROOT/pages/testing.adoc new file mode 100644 index 000000000..f41d44ce1 --- /dev/null +++ b/quarkus-morphium/docs/modules/ROOT/pages/testing.adoc @@ -0,0 +1,296 @@ += Testing + +include::./includes/attributes.adoc[] + +The extension supports two complementary test strategies that can be used side by side +in the same test suite. + +[cols="1,1,1",options="header"] +|=== +| Strategy | MongoDB | Startup speed + +| Dev Services (automatic container) +| real MongoDB in Docker +| slower (container pull + boot) + +| `InMemDriver` via `InMemMorphiumTestProfile` +| in-process, no Docker +| fast (JVM only) +|=== + +[#dev-services] +== Dev Services (automatic MongoDB container) + +When `quarkus.morphium.hosts` is not set, the extension starts a MongoDB container automatically +in **test** mode. +No configuration is required: + +[source,java] +---- +@QuarkusTest // <1> +class ProductRepositoryTest { + + @Inject ProductRepository repository; + @Inject Morphium morphium; + + @BeforeEach + void setUp() { + morphium.dropCollection(ProductEntity.class); + morphium.ensureIndicesFor(ProductEntity.class); + } + + @Test + void savePersistsEntity() { + var product = new ProductEntity(); + product.setName("Widget"); + + var saved = repository.save(product); + + assertThat(saved.getId()).isNotNull(); + } +} +---- +<1> Dev Services automatically starts a MongoDB container – nothing else needed. + +To customise the container image: + +[source,properties] +---- +# src/test/resources/application.properties +%test.quarkus.morphium.devservices.image-name=mongo:7 +---- + +[#inmem] +== InMemDriver (no Docker) + +For tests that should run without Docker, Morphium's built-in `InMemDriver` processes all +operations inside the JVM. +The `quarkus-morphium-testing` artifact ships a ready-made Quarkus test profile that sets +the required configuration overrides. + +=== Dependency + +Add `quarkus-morphium-testing` as a **test** dependency: + +[source,xml,subs=attributes+] +---- + + {quarkus-morphium-groupid} + quarkus-morphium-testing + {quarkus-morphium-version} + test + +---- + +=== Usage + +[source,java] +---- +import de.caluga.morphium.quarkus.testing.InMemMorphiumTestProfile; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; + +@QuarkusTest +@TestProfile(InMemMorphiumTestProfile.class) // <1> +class ProductRepositoryInMemTest { + + @Inject ProductRepository repository; + @Inject Morphium morphium; + + @BeforeEach + void setUp() { + morphium.dropCollection(ProductEntity.class); + morphium.ensureIndicesFor(ProductEntity.class); + } + + @Test + void savePersistsEntity() { + var product = new ProductEntity(); + product.setName("Widget"); + + var saved = repository.save(product); + + assertThat(saved.getId()).isNotNull(); + } +} +---- +<1> All Morphium operations run in-process; no container is started. + +`InMemMorphiumTestProfile` applies the following configuration overrides: + +[source,properties] +---- +quarkus.morphium.driver-name=InMemDriver +quarkus.morphium.database=inmem-test +quarkus.morphium.devservices.enabled=false +---- + +=== What the InMemDriver supports + +The in-memory driver is a full implementation of the Morphium driver interface. +It supports: + +* CRUD operations (`store`, `delete`, query) +* Index creation via `ensureIndicesFor` (no-op, always succeeds) +* Collection management (`dropCollection`, `clearCollection`) +* Transactions (best-effort – no multi-document atomicity) +* `@Version` optimistic locking + +[CAUTION] +==== +The `InMemDriver` does not support: + +* Aggregation pipelines with complex stages (`$lookup`, `$facet`) +* Geospatial queries +==== + +[#mixing] +== Mixing Both Strategies + +Dev Services tests and `InMemDriver` tests coexist without any extra configuration. +Quarkus detects the different `@TestProfile` on the `InMemDriver` test classes and +**restarts the application context once** when switching between profiles. +All other tests in the same profile group share a single context and start up only once. + +[source] +---- +ProductRepositoryTest → @QuarkusTest → shared Dev Services context +ProductServiceTest → @QuarkusTest → shared Dev Services context + +ProductRepositoryInMemTest → @TestProfile(InMemMorphiumTestProfile.class) → separate InMem context +CampaignRepositoryInMemTest → @TestProfile(InMemMorphiumTestProfile.class) → same InMem context (reused) +---- + +The trade-off: InMem tests run faster individually but incur a one-time restart cost when +the test runner first encounters the profile. +Use InMem tests for pure repository / persistence-layer tests and Dev Services tests for +integration tests that require real MongoDB behaviour (e.g. aggregations, TTL indexes). + +[#test-isolation] +== Test Isolation + +Both strategies use the same isolation pattern: +drop the collection and recreate indexes before each test. + +[source,java] +---- +@BeforeEach +void setUp() { + morphium.dropCollection(MyEntity.class); // <1> + morphium.ensureIndicesFor(MyEntity.class); // <2> +} +---- +<1> Removes all documents and the collection itself. +<2> Re-creates declared indexes – important for unique-constraint tests. + +Alternatively, use `morphium.clearCollection(MyEntity.class)` to delete all documents +while retaining the collection and its indexes (faster when index creation is expensive). + +[#transaction-testing] +== Testing Transactions + +`@MorphiumTransactional` requires a replica set. The `InMemDriver` does not provide true +multi-document atomicity, so transaction tests must use Dev Services (replica set is enabled +by default — no extra configuration needed). + +[source,java] +---- +@QuarkusTest +class OrderServiceTransactionTest { + + @Inject OrderService orderService; + @Inject Morphium morphium; + + @BeforeEach + void setUp() { + morphium.dropCollection(Order.class); + morphium.dropCollection(Payment.class); + } + + @Test + void commitOnSuccess() { + orderService.placeOrder(new Order("A1"), new Payment(42.0)); + assertThat(morphium.createQueryFor(Order.class).countAll()).isEqualTo(1); + } + + @Test + void rollbackOnFailure() { + assertThrows(RuntimeException.class, () -> + orderService.placeOrderThatFails(new Order("A2"), new Payment(0.0))); + assertThat(morphium.createQueryFor(Order.class).countAll()).isEqualTo(0); + } +} +---- + +See xref:transactions.adoc[Transactions] for details on the transaction lifecycle. + +[#integration-tests] +== Integration Tests + +For `@QuarkusIntegrationTest` (tests running against the packaged application), Dev Services +are still available. The container started during the build phase is reused: + +[source,java] +---- +import io.quarkus.test.junit.QuarkusIntegrationTest; + +@QuarkusIntegrationTest +class ProductResourceIT { + + @Test + void listProductsReturnsOk() { + given() + .when().get("/products") + .then().statusCode(200); + } +} +---- + +For a complete example of integration tests with the Morphium extension, see the +link:{showcase-github-url}[quarkus-morphium-showcase] project. + +[#inmem-limitations] +== InMemDriver Limitations Reference + +.InMemDriver feature support +[cols="2,1,2",options="header"] +|=== +| Feature | Supported | Notes + +| CRUD (`store`, `delete`, query) +| yes +| Full support + +| Index creation (`ensureIndicesFor`) +| yes +| No-op, always succeeds + +| Collection management (`drop`, `clear`) +| yes +| Full support + +| `@Version` optimistic locking +| yes +| Full support + +| Simple aggregations (`$match`, `$group`, `$sort`) +| partial +| Basic stages only + +| Complex aggregations (`$lookup`, `$facet`, `$graphLookup`) +| no +| Use Dev Services for these tests + +| Geospatial queries +| no +| Use Dev Services + +| Multi-document transactions +| best-effort +| No true atomicity — use Dev Services (replica set enabled by default) + +| Change streams +| no +| Use Dev Services + +|=== diff --git a/quarkus-morphium/docs/modules/ROOT/pages/transactions.adoc b/quarkus-morphium/docs/modules/ROOT/pages/transactions.adoc new file mode 100644 index 000000000..509eb5c8b --- /dev/null +++ b/quarkus-morphium/docs/modules/ROOT/pages/transactions.adoc @@ -0,0 +1,171 @@ += Transactions + +include::./includes/attributes.adoc[] + +The Quarkus Morphium extension provides declarative transaction support via the +`@MorphiumTransactional` annotation. On success the transaction is committed; on any +exception it is rolled back and the exception is re-thrown. + +== Replica-Set Requirement + +[IMPORTANT] +==== +MongoDB multi-document transactions require a *replica set* (or sharded cluster). A +standalone MongoDB instance does not support transactions. + +Dev Services starts MongoDB as a single-node replica set by default, so transactions work +out of the box. See xref:dev-services.adoc[Dev Services] for details. +==== + +== @MorphiumTransactional + +Annotate any CDI bean method to wrap it in a Morphium transaction: + +[source,java] +---- +import de.caluga.morphium.Morphium; +import de.caluga.morphium.quarkus.transaction.MorphiumTransactional; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +@ApplicationScoped +public class OrderService { + + @Inject Morphium morphium; + + @MorphiumTransactional + public void placeOrder(Order order, Payment payment) { + morphium.store(order); + morphium.store(payment); + // auto-commit on success, auto-rollback on exception + } +} +---- + +The annotation may also be placed on a *class* to apply it to all business methods. + +== Transaction Lifecycle + +The interceptor executes at priority `PLATFORM_BEFORE + 200` and follows this sequence: + +1. `morphium.startTransaction()` +2. Execute the business method +3. Fire `BEFORE_COMMIT` CDI event +4. `morphium.commitTransaction()` +5. Fire `AFTER_COMMIT` CDI event + +On exception: + +1. `morphium.abortTransaction()` +2. Fire `AFTER_ROLLBACK` CDI event (with the causing `Exception`) +3. Re-throw the exception + +== Transaction Lifecycle Events + +Use `@Observes` with the `@MorphiumTxPhase` qualifier to react to transaction phases: + +[source,java] +---- +import de.caluga.morphium.quarkus.transaction.*; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import org.jboss.logging.Logger; + +import static de.caluga.morphium.quarkus.transaction.MorphiumTransactionEvent.Phase.*; + +@ApplicationScoped +public class AuditObserver { + + private static final Logger LOG = Logger.getLogger(AuditObserver.class); + + void afterCommit(@Observes @MorphiumTxPhase(AFTER_COMMIT) MorphiumTransactionEvent e) { + // e.g. publish a domain event + } + + void afterRollback(@Observes @MorphiumTxPhase(AFTER_ROLLBACK) MorphiumTransactionEvent e) { + LOG.warn("Transaction rolled back", e.getFailure()); + } +} +---- + +.Transaction phases +[cols="1,2,1",options="header"] +|=== +| Phase | When it fires | `getFailure()` + +| `BEFORE_COMMIT` +| After the business method succeeds, before `commitTransaction()` +| `null` + +| `AFTER_COMMIT` +| After `commitTransaction()` succeeds +| `null` + +| `AFTER_ROLLBACK` +| After `abortTransaction()` due to an exception +| The causing `Exception` +|=== + +== CosmosDB Compatibility + +When running against Azure CosmosDB, Morphium auto-detects the backend via the +`hello` handshake. Because CosmosDB does not support multi-document transactions, +`@MorphiumTransactional` **gracefully degrades**: the interceptor skips transaction +wrapping and executes the method directly. + +A single WARN-level message is logged at application startup if CosmosDB is +detected, and each subsequent invocation is logged at DEBUG level: + +[source,text] +---- +WARN CosmosDB detected — @MorphiumTransactional methods will execute WITHOUT + transaction wrapping. Individual ops remain atomic; multi-document rollback is unavailable. +---- + +[NOTE] +==== +On CosmosDB, lifecycle events (`BEFORE_COMMIT`, `AFTER_COMMIT`, `AFTER_ROLLBACK`) +are still fired so that observers (outbox publishers, audit logging, cleanup, etc.) +continue to work. The only difference is that there is no real transaction backing +them — individual Morphium operations (`store`, `inc`, `set`, etc.) remain atomic +at the document level, but multi-document rollback is unavailable. +==== + +== Testing Transactions + +To test `@MorphiumTransactional` methods, you need a replica set. Dev Services starts one +by default, so no extra configuration is needed: + +[source,java] +---- +@QuarkusTest +class OrderServiceTest { + + @Inject OrderService orderService; + @Inject Morphium morphium; + + @BeforeEach + void setUp() { + morphium.dropCollection(Order.class); + morphium.dropCollection(Payment.class); + } + + @Test + void placeOrderCommitsOnSuccess() { + orderService.placeOrder(new Order("A1"), new Payment(42.0)); + assertThat(morphium.createQueryFor(Order.class).countAll()).isEqualTo(1); + assertThat(morphium.createQueryFor(Payment.class).countAll()).isEqualTo(1); + } + + @Test + void placeOrderRollsBackOnFailure() { + assertThrows(RuntimeException.class, () -> + orderService.placeOrderThatFails(new Order("A2"), new Payment(0.0))); + assertThat(morphium.createQueryFor(Order.class).countAll()).isEqualTo(0); + } +} +---- + +NOTE: The `InMemDriver` does not support true multi-document transactions. Use Dev Services +(replica set is enabled by default) for transaction testing. See xref:testing.adoc[Testing] +for more strategies. diff --git a/quarkus-morphium/integration-tests/pom.xml b/quarkus-morphium/integration-tests/pom.xml new file mode 100644 index 000000000..f8b333ec1 --- /dev/null +++ b/quarkus-morphium/integration-tests/pom.xml @@ -0,0 +1,104 @@ + + + 4.0.0 + + + de.caluga + quarkus-morphium-parent + 6.3.2-SNAPSHOT + + + quarkus-morphium-integration-tests + Quarkus Morphium Extension – Integration Tests + + + true + + + + + + ${project.groupId} + quarkus-morphium + ${project.version} + + + + + io.quarkus + quarkus-smallrye-health + + + + + io.quarkus + quarkus-rest + + + io.quarkus + quarkus-rest-jackson + + + + + io.quarkus + quarkus-junit + test + + + ${project.groupId} + quarkus-morphium-testing + ${project.version} + test + + + io.rest-assured + rest-assured + test + + + org.assertj + assertj-core + test + + + + org.testcontainers + testcontainers + test + + + + + + + io.quarkus + quarkus-maven-plugin + ${quarkus.version} + true + + + + build + generate-code + generate-code-tests + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.5 + + + org.jboss.logmanager.LogManager + + + + + + diff --git a/quarkus-morphium/integration-tests/src/main/resources/application.properties b/quarkus-morphium/integration-tests/src/main/resources/application.properties new file mode 100644 index 000000000..28f45c4fd --- /dev/null +++ b/quarkus-morphium/integration-tests/src/main/resources/application.properties @@ -0,0 +1,7 @@ +# Integration-test application config. +# Uses Morphium's InMemDriver – no MongoDB process or Docker required. +quarkus.morphium.database=it-db +quarkus.morphium.driver-name=InMemDriver + +# Suppress Dev Services (InMemDriver is used instead) +quarkus.morphium.devservices.enabled=false diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/AddCategoryMigration.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/AddCategoryMigration.java new file mode 100644 index 000000000..a77b14e50 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/AddCategoryMigration.java @@ -0,0 +1,36 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.quarkus.migration.Execution; +import de.caluga.morphium.quarkus.migration.MorphiumChangeUnit; + +/** + * Test migration: adds a second item with a different tag. + */ +@MorphiumChangeUnit(id = "002-add-category", order = "002", author = "test") +public class AddCategoryMigration { + + @Execution + public void execute(Morphium morphium) { + ItemEntity item = new ItemEntity(); + item.setName("Migrated Gadget"); + item.setPrice(29.99); + item.setTag("migration-v2"); + morphium.store(item); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/AddressEmbedded.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/AddressEmbedded.java new file mode 100644 index 000000000..7eb3983ec --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/AddressEmbedded.java @@ -0,0 +1,42 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.annotations.Embedded; +import de.caluga.morphium.annotations.Property; + +/** + * Embedded address document used in embedded-document integration tests. + */ +@Embedded +public class AddressEmbedded { + + @Property(fieldName = "street") + private String street; + + @Property(fieldName = "city") + private String city; + + @Property(fieldName = "zip") + private String zip; + + public String getStreet() { return street; } + public void setStreet(String street) { this.street = street; } + public String getCity() { return city; } + public void setCity(String city) { this.city = city; } + public String getZip() { return zip; } + public void setZip(String zip) { this.zip = zip; } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/CustomerEntity.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/CustomerEntity.java new file mode 100644 index 000000000..51c0465a8 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/CustomerEntity.java @@ -0,0 +1,41 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.annotations.*; + +/** + * Test entity that contains an {@link AddressEmbedded} sub-document. + */ +@Entity(collectionName = "it_customers") +public class CustomerEntity { + + @Id + private String id; + + @Property(fieldName = "name") + private String name; + + @Property(fieldName = "address") + private AddressEmbedded address; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public AddressEmbedded getAddress() { return address; } + public void setAddress(AddressEmbedded a) { this.address = a; } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/DockerAvailableCondition.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/DockerAvailableCondition.java new file mode 100644 index 000000000..8ab7b9a80 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/DockerAvailableCondition.java @@ -0,0 +1,98 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import org.junit.jupiter.api.extension.ConditionEvaluationResult; +import org.junit.jupiter.api.extension.ExecutionCondition; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.testcontainers.DockerClientFactory; + +/** + * JUnit 5 {@link ExecutionCondition} that disables a test class when no Docker daemon is + * reachable, evaluated before {@code QuarkusTestExtension} boots the application. + * + *

    Why this exists instead of a {@code @BeforeAll} assumption: {@code @QuarkusTest} + * boots the Quarkus application (including Dev Services) as part of + * {@code QuarkusTestExtension}'s {@code beforeAll} callback, which JUnit invokes strictly + * before the test class's own {@code @BeforeAll} methods. By the time a + * {@code @BeforeAll}-based {@code assumeTrue(...)} check would run, the application has + * already tried (and, without Docker, failed) to boot — the assumption never gets a chance + * to skip anything. An {@link ExecutionCondition} registered via {@code @ExtendWith} + * participates in JUnit's {@code shouldBeStopped}/container-execution evaluation, which runs + * ahead of any extension's own {@code beforeAll}, including {@code QuarkusTestExtension}'s — + * so disabling here actually prevents the boot attempt. + * + *

    Why not {@code testcontainers-junit-jupiter}'s {@code @EnabledIfDockerAvailable}: + * see the class-level Javadoc on {@link MorphiumTransactionalTest} — under Quarkus's test + * classloading, that annotation's detector reported Docker as unavailable even while Dev + * Services had already started a real container in the same JVM. Calling + * {@code DockerClientFactory.instance().isDockerAvailable()} directly — the same class Dev + * Services itself uses — avoids that discrepancy. + */ +public class DockerAvailableCondition implements ExecutionCondition { + + private static final ConditionEvaluationResult DOCKER_AVAILABLE = + ConditionEvaluationResult.enabled("Docker is available"); + + private static final ConditionEvaluationResult DOCKER_NOT_AVAILABLE = + ConditionEvaluationResult.disabled( + "Docker is not available — skipping tests that require a MongoDB replica set via Dev Services"); + + @Override + public ConditionEvaluationResult evaluateExecutionCondition(ExtensionContext context) { + // Only perform the actual Docker check at the container (class) level, i.e. before + // QuarkusTestExtension boots the app and swaps in its own test classloader. Once the + // class-level check has enabled the container, JUnit re-evaluates all registered + // ExecutionConditions again for each individual test method; the class-level result + // already determined whether the whole container should run, so per-method + // evaluations simply trust that decision instead of repeating the check. + if (context.getTestMethod().isPresent()) { + return DOCKER_AVAILABLE; + } + return isDockerAvailable() ? DOCKER_AVAILABLE : DOCKER_NOT_AVAILABLE; + } + + /** + * Calls {@code DockerClientFactory.instance().isDockerAvailable()} with the current + * thread's context classloader temporarily forced to this class's own defining + * classloader. + * + *

    Without this, {@code isDockerAvailable()} fails with a hard + * {@code ServiceConfigurationError} ("... not a subtype") instead of returning a clean + * {@code true}/{@code false} when the JUnit Platform Launcher's forked JVM (Surefire, + * {@code reuseForks=true} by default) has already run an earlier {@code @QuarkusTest} + * class in the same fork: Quarkus's own {@code QuarkusClassLoader} for that earlier class + * can be left installed as the thread's context classloader, and {@code DockerClientFactory} + * internally does a plain {@code ServiceLoader.load(DockerClientProviderStrategy.class)}, + * which resolves against the context classloader by default. That classloader sees a + * different (already-loaded, incompatible) copy of the testcontainers service classes + * than the one this extension class was loaded with, so the {@code ServiceLoader} finds + * two versions of the same service type and rejects it as "not a subtype". Forcing the + * context classloader to this class's own loader for the duration of the call guarantees + * {@code ServiceLoader} resolves against the same, single copy of testcontainers that this + * extension itself uses. + */ + private static boolean isDockerAvailable() { + Thread currentThread = Thread.currentThread(); + ClassLoader previous = currentThread.getContextClassLoader(); + currentThread.setContextClassLoader(DockerAvailableCondition.class.getClassLoader()); + try { + return DockerClientFactory.instance().isDockerAvailable(); + } finally { + currentThread.setContextClassLoader(previous); + } + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/FailingMigration.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/FailingMigration.java new file mode 100644 index 000000000..2cb3dbfbe --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/FailingMigration.java @@ -0,0 +1,40 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.quarkus.migration.Execution; +import de.caluga.morphium.quarkus.migration.MorphiumChangeUnit; +import de.caluga.morphium.quarkus.migration.RollbackExecution; + +/** + * Test migration that always fails. Used to verify rollback behavior. + */ +@MorphiumChangeUnit(id = "999-failing", order = "999", author = "test") +public class FailingMigration { + + public static volatile boolean rollbackExecuted = false; + + @Execution + public void execute(Morphium morphium) { + throw new RuntimeException("Intentional failure for rollback test"); + } + + @RollbackExecution + public void rollback(Morphium morphium) { + rollbackExecuted = true; + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/InitItemsMigration.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/InitItemsMigration.java new file mode 100644 index 000000000..e3b100e18 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/InitItemsMigration.java @@ -0,0 +1,42 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.quarkus.migration.Execution; +import de.caluga.morphium.quarkus.migration.MorphiumChangeUnit; +import de.caluga.morphium.quarkus.migration.RollbackExecution; + +/** + * Test migration: inserts initial items into the database. + */ +@MorphiumChangeUnit(id = "001-init-items", order = "001", author = "test") +public class InitItemsMigration { + + @Execution + public void execute(Morphium morphium) { + ItemEntity item = new ItemEntity(); + item.setName("Migrated Widget"); + item.setPrice(19.99); + item.setTag("migration-v1"); + morphium.store(item); + } + + @RollbackExecution + public void rollback(Morphium morphium) { + morphium.dropCollection(ItemEntity.class); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/ItemEntity.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/ItemEntity.java new file mode 100644 index 000000000..cf3ae59be --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/ItemEntity.java @@ -0,0 +1,70 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.annotations.Entity; +import de.caluga.morphium.annotations.Id; +import de.caluga.morphium.annotations.Property; +import de.caluga.morphium.annotations.Version; +import de.caluga.morphium.annotations.lifecycle.Lifecycle; +import de.caluga.morphium.annotations.lifecycle.PreStore; + +/** + * Minimal test entity used across all integration tests. + * Exercises @Entity, @Id, @Property, @Version and @Lifecycle / @PreStore. + */ +@Entity(collectionName = "it_items") +@Lifecycle +public class ItemEntity { + + @Id + private String id; + + @Property(fieldName = "name") + private String name; + + @Property(fieldName = "price") + private double price; + + @Version + @Property(fieldName = "version") + private long version; + + @Property(fieldName = "tag") + private String tag; + + @PreStore + public void onStore() { + if (tag == null) tag = "default"; + } + + // --- accessors --- + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public double getPrice() { return price; } + public void setPrice(double price) { this.price = price; } + + public long getVersion() { return version; } + public void setVersion(long version) { this.version = version; } + + public String getTag() { return tag; } + public void setTag(String tag) { this.tag = tag; } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/ItemRepository.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/ItemRepository.java new file mode 100644 index 000000000..f3d0f7743 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/ItemRepository.java @@ -0,0 +1,62 @@ +package de.caluga.morphium.quarkus.it; + +import jakarta.data.Limit; +import jakarta.data.repository.By; +import jakarta.data.repository.CrudRepository; +import jakarta.data.repository.Delete; +import jakarta.data.repository.Find; +import jakarta.data.repository.Insert; +import jakarta.data.repository.OrderBy; +import jakarta.data.repository.Repository; +import jakarta.data.repository.Save; +import jakarta.data.repository.Update; + +import java.util.List; + +/** + * Jakarta Data repository for {@link ItemEntity}. + * Extends CrudRepository for full CRUD support plus custom query methods. + */ +@Repository +public interface ItemRepository extends CrudRepository { + + List findByName(String name); + + List findByPriceGreaterThan(double minPrice); + + long countByTag(String tag); + + boolean existsByName(String name); + + @Find + List searchByTag(@By("tag") String tag); + + @Find + ItemEntity findOneByName(@By("name") String name); + + @Find + @OrderBy("price") + List findByTagSortedByPrice(@By("tag") String tag); + + @Find + @OrderBy(value = "price", descending = true) + List findByTagSortedByPriceDesc(@By("tag") String tag); + + @Find + List findWithLimit(@By("tag") String tag, Limit limit); + + @Delete + void removeByTag(@By("tag") String tag); + + @Insert + ItemEntity addItem(ItemEntity item); + + @Insert + List addItems(List items); + + @Save + ItemEntity storeItem(ItemEntity item); + + @Update + ItemEntity updateItem(ItemEntity item); +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumCrudTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumCrudTest.java new file mode 100644 index 000000000..6e3dd149f --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumCrudTest.java @@ -0,0 +1,122 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * End-to-end CRUD tests using the injected {@link Morphium} bean and the InMemDriver. + * Covers: store, find-by-field, find-all, count, delete. + */ +@QuarkusTest +@DisplayName("Morphium CRUD operations") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumCrudTest { + + @Inject + Morphium morphium; + + // Shared ID across ordered tests + private static String storedId; + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + @Test + @Order(1) + @DisplayName("store() sets id and returns persisted entity") + void store_setsId() { + var item = new ItemEntity(); + item.setName("Widget"); + item.setPrice(9.99); + + morphium.store(item); + + assertThat(item.getId()) + .as("id must be assigned after store()") + .isNotNull() + .isNotBlank(); + + storedId = item.getId(); + } + + @Test + @Order(2) + @DisplayName("@PreStore lifecycle hook runs on store()") + void preStore_lifecycleHookRuns() { + var item = new ItemEntity(); + item.setName("Gadget"); + morphium.store(item); + + // @PreStore sets tag="default" when null + assertThat(item.getTag()).isEqualTo("default"); + } + + @Test + @Order(3) + @DisplayName("createQueryFor().f().eq().get() finds stored entity") + void query_findByField() { + ItemEntity found = morphium.createQueryFor(ItemEntity.class) + .f("name").eq("Widget") + .get(); + + assertThat(found).isNotNull(); + assertThat(found.getPrice()).isEqualTo(9.99); + assertThat(found.getId()).isEqualTo(storedId); + } + + @Test + @Order(4) + @DisplayName("createQueryFor().asList() returns all stored entities") + void query_findAll() { + List all = morphium.createQueryFor(ItemEntity.class).asList(); + assertThat(all).hasSizeGreaterThanOrEqualTo(2); + } + + @Test + @Order(5) + @DisplayName("createQueryFor().countAll() reflects the stored count") + void query_count() { + long count = morphium.createQueryFor(ItemEntity.class).countAll(); + assertThat(count).isGreaterThanOrEqualTo(2); + } + + @Test + @Order(6) + @DisplayName("delete() removes the entity; subsequent query returns null") + void delete_removesEntity() { + ItemEntity toDelete = morphium.createQueryFor(ItemEntity.class) + .f("name").eq("Widget") + .get(); + assertThat(toDelete).isNotNull(); + + morphium.delete(toDelete); + + ItemEntity afterDelete = morphium.createQueryFor(ItemEntity.class) + .f("name").eq("Widget") + .get(); + assertThat(afterDelete).isNull(); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataAggregateTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataAggregateTest.java new file mode 100644 index 000000000..df337420a --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataAggregateTest.java @@ -0,0 +1,115 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for Jakarta Data #8: JDQL Aggregate Functions. + * Tests COUNT, SUM, AVG, MIN, MAX with global aggregation (_id: null). + */ +@QuarkusTest +@DisplayName("Jakarta Data JDQL Aggregate Functions") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataAggregateTest { + + @Inject + OrderRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.clearCollection(OrderEntity.class); + + // 5 OPEN orders: 100, 200, 300, 400, 500 + for (int i = 1; i <= 5; i++) { + createOrder("C" + i, i * 100.0, "OPEN"); + } + // 5 CLOSED orders: 600, 700, 800, 900, 1000 + for (int i = 6; i <= 10; i++) { + createOrder("C" + i, i * 100.0, "CLOSED"); + } + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + @Test + @Order(1) + @DisplayName("COUNT(this) WHERE status = 'OPEN'") + void count_byStatus() { + long count = repository.countByStatusJdql("OPEN"); + assertThat(count).isEqualTo(5L); + } + + @Test + @Order(2) + @DisplayName("SUM(amount) WHERE status = 'OPEN'") + void sum_byStatus() { + double sum = repository.sumAmountByStatus("OPEN"); + assertThat(sum).isEqualTo(1500.0); + } + + @Test + @Order(3) + @DisplayName("AVG(amount) WHERE status = 'OPEN'") + void avg_byStatus() { + double avg = repository.avgAmountByStatus("OPEN"); + assertThat(avg).isEqualTo(300.0); + } + + @Test + @Order(4) + @DisplayName("MIN(amount) WHERE status = 'OPEN'") + void min_byStatus() { + double min = repository.minAmountByStatus("OPEN"); + assertThat(min).isEqualTo(100.0); + } + + @Test + @Order(5) + @DisplayName("MAX(amount) WHERE status = 'OPEN'") + void max_byStatus() { + double max = repository.maxAmountByStatus("OPEN"); + assertThat(max).isEqualTo(500.0); + } + + @Test + @Order(6) + @DisplayName("COUNT(this) WHERE amount > 500") + void count_withFilter() { + long count = repository.countByAmountGreaterThan(500.0); + assertThat(count).isEqualTo(5L); // 600, 700, 800, 900, 1000 + } + + @Test + @Order(7) + @DisplayName("COUNT(this) WHERE status = 'NONEXISTENT' → 0") + void count_noResults() { + long count = repository.countByStatusJdql("NONEXISTENT"); + assertThat(count).isEqualTo(0L); + } + + @Test + @Order(8) + @DisplayName("SUM(amount) WHERE status = 'NONEXISTENT' → 0.0") + void sum_noResults() { + double sum = repository.sumAmountByStatus("NONEXISTENT"); + assertThat(sum).isEqualTo(0.0); + } + + private void createOrder(String customerId, double amount, String status) { + var order = new OrderEntity(); + order.setCustomerId(customerId); + order.setAmount(amount); + order.setStatus(status); + morphium.store(order); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataAnnotatedQueryTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataAnnotatedQueryTest.java new file mode 100644 index 000000000..9e6be4340 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataAnnotatedQueryTest.java @@ -0,0 +1,210 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.data.Limit; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Integration tests for Jakarta Data Phase 4: @Find, @By, @OrderBy, + * @Delete, @Insert, @Save, @Update annotations. + */ +@QuarkusTest +@DisplayName("Jakarta Data Annotated Queries (@Find/@By/@OrderBy)") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataAnnotatedQueryTest { + + @Inject + ItemRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.clearCollection(ItemEntity.class); + + createItem("Apple", 1.50, "fruit"); + createItem("Banana", 0.80, "fruit"); + createItem("Carrot", 2.00, "vegetable"); + createItem("Daikon", 3.50, "vegetable"); + createItem("Eggplant", 2.50, "vegetable"); + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + // -- @Find / @By tests -- + + @Test + @Order(1) + @DisplayName("@Find @By tag returns matching entities") + void findByTag() { + List fruits = repository.searchByTag("fruit"); + + assertThat(fruits).hasSize(2); + assertThat(fruits).allSatisfy(i -> assertThat(i.getTag()).isEqualTo("fruit")); + } + + @Test + @Order(2) + @DisplayName("@Find @By name returns single entity") + void findOneByName() { + ItemEntity item = repository.findOneByName("Apple"); + + assertThat(item).isNotNull(); + assertThat(item.getName()).isEqualTo("Apple"); + assertThat(item.getPrice()).isEqualTo(1.50); + } + + @Test + @Order(3) + @DisplayName("@Find @By name throws EmptyResultException for non-existing") + void findOneByName_notFound() { + assertThatThrownBy(() -> repository.findOneByName("NonExistent")) + .isInstanceOf(jakarta.data.exceptions.EmptyResultException.class); + } + + // -- @Find / @OrderBy tests -- + + @Test + @Order(4) + @DisplayName("@Find @OrderBy(price) sorts ascending") + void findByTagSortedByPriceAsc() { + List vegs = repository.findByTagSortedByPrice("vegetable"); + + assertThat(vegs).hasSize(3); + assertThat(vegs.get(0).getName()).isEqualTo("Carrot"); // 2.00 + assertThat(vegs.get(1).getName()).isEqualTo("Eggplant"); // 2.50 + assertThat(vegs.get(2).getName()).isEqualTo("Daikon"); // 3.50 + } + + @Test + @Order(5) + @DisplayName("@Find @OrderBy(price, descending=true) sorts descending") + void findByTagSortedByPriceDesc() { + List vegs = repository.findByTagSortedByPriceDesc("vegetable"); + + assertThat(vegs).hasSize(3); + assertThat(vegs.get(0).getName()).isEqualTo("Daikon"); // 3.50 + assertThat(vegs.get(1).getName()).isEqualTo("Eggplant"); // 2.50 + assertThat(vegs.get(2).getName()).isEqualTo("Carrot"); // 2.00 + } + + // -- @Find with Limit -- + + @Test + @Order(6) + @DisplayName("@Find with Limit restricts results") + void findWithLimit() { + List result = repository.findWithLimit("vegetable", Limit.of(2)); + + assertThat(result).hasSize(2); + } + + // -- @Delete tests -- + + @Test + @Order(7) + @DisplayName("@Delete @By removes matching entities") + void deleteByTag() { + assertThat(repository.searchByTag("fruit")).hasSize(2); + + repository.removeByTag("fruit"); + + assertThat(repository.searchByTag("fruit")).isEmpty(); + // Vegetables should be untouched + assertThat(repository.searchByTag("vegetable")).hasSize(3); + } + + // -- @Insert tests -- + + @Test + @Order(8) + @DisplayName("@Insert single entity") + void insertSingle() { + morphium.clearCollection(ItemEntity.class); + + var item = new ItemEntity(); + item.setName("Fig"); + item.setPrice(4.00); + item.setTag("fruit"); + + ItemEntity result = repository.addItem(item); + + assertThat(result).isNotNull(); + assertThat(result.getId()).isNotNull(); + assertThat(repository.findOneByName("Fig")).isNotNull(); + } + + @Test + @Order(9) + @DisplayName("@Insert list of entities") + void insertList() { + morphium.clearCollection(ItemEntity.class); + + var a = new ItemEntity(); + a.setName("A"); + a.setPrice(1.0); + + var b = new ItemEntity(); + b.setName("B"); + b.setPrice(2.0); + + List result = repository.addItems(List.of(a, b)); + + assertThat(result).hasSize(2); + assertThat(repository.findByName("A")).hasSize(1); + assertThat(repository.findByName("B")).hasSize(1); + } + + // -- @Save tests -- + + @Test + @Order(10) + @DisplayName("@Save stores entity (upsert)") + void saveItem() { + morphium.clearCollection(ItemEntity.class); + + var item = new ItemEntity(); + item.setName("Grape"); + item.setPrice(5.00); + + ItemEntity saved = repository.storeItem(item); + + assertThat(saved).isNotNull(); + assertThat(saved.getId()).isNotNull(); + } + + // -- @Update tests -- + + @Test + @Order(11) + @DisplayName("@Update modifies existing entity") + void updateItem() { + ItemEntity existing = repository.findOneByName("Apple"); + assertThat(existing).isNotNull(); + + existing.setPrice(9.99); + repository.updateItem(existing); + + ItemEntity updated = repository.findOneByName("Apple"); + assertThat(updated.getPrice()).isEqualTo(9.99); + } + + private void createItem(String name, double price, String tag) { + var item = new ItemEntity(); + item.setName(name); + item.setPrice(price); + item.setTag(tag); + morphium.store(item); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataAsyncTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataAsyncTest.java new file mode 100644 index 000000000..80eb5b09c --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataAsyncTest.java @@ -0,0 +1,127 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for CompletionStage async support in Jakarta Data repositories. + */ +@QuarkusTest +@DisplayName("Jakarta Data Async (CompletionStage) Support") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataAsyncTest { + + @Inject + OrderRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.clearCollection(OrderEntity.class); + for (int i = 1; i <= 10; i++) { + var order = new OrderEntity(); + order.setCustomerId("CUST-" + i); + order.setAmount(i * 100.0); + order.setStatus(i <= 5 ? "OPEN" : "CLOSED"); + morphium.store(order); + } + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + @Test + @Order(1) + @DisplayName("#1 Query derivation async: findByStatusAsync returns list") + void queryDerivation_findByStatusAsync() throws Exception { + CompletionStage> stage = repository.findByStatusAsync("OPEN"); + List result = stage.toCompletableFuture().get(5, TimeUnit.SECONDS); + + assertThat(result).hasSize(5); + assertThat(result).allMatch(o -> "OPEN".equals(o.getStatus())); + } + + @Test + @Order(2) + @DisplayName("#2 Query derivation async: findByCustomerIdAsync returns Optional") + void queryDerivation_findByCustomerIdAsync() throws Exception { + CompletionStage> stage = repository.findByCustomerIdAsync("CUST-3"); + Optional result = stage.toCompletableFuture().get(5, TimeUnit.SECONDS); + + assertThat(result).isPresent(); + assertThat(result.get().getCustomerId()).isEqualTo("CUST-3"); + } + + @Test + @Order(3) + @DisplayName("#3 Query derivation async: findByCustomerIdAsync returns empty Optional") + void queryDerivation_findByCustomerIdAsync_notFound() throws Exception { + CompletionStage> stage = repository.findByCustomerIdAsync("NONEXISTENT"); + Optional result = stage.toCompletableFuture().get(5, TimeUnit.SECONDS); + + assertThat(result).isEmpty(); + } + + @Test + @Order(4) + @DisplayName("#4 @Find async: findAsyncByStatus returns sorted list") + void findAnnotation_async() throws Exception { + CompletionStage> stage = repository.findAsyncByStatus("OPEN"); + List result = stage.toCompletableFuture().get(5, TimeUnit.SECONDS); + + assertThat(result).hasSize(5); + assertThat(result).allMatch(o -> "OPEN".equals(o.getStatus())); + // Verify sorted by amount ASC (@OrderBy("amount")) + for (int i = 1; i < result.size(); i++) { + assertThat(result.get(i).getAmount()).isGreaterThanOrEqualTo(result.get(i - 1).getAmount()); + } + } + + @Test + @Order(5) + @DisplayName("#5 @Query JDQL async: queryByStatusAsync returns sorted list") + void jdqlQuery_async() throws Exception { + CompletionStage> stage = repository.queryByStatusAsync("CLOSED"); + List result = stage.toCompletableFuture().get(5, TimeUnit.SECONDS); + + assertThat(result).hasSize(5); + assertThat(result).allMatch(o -> "CLOSED".equals(o.getStatus())); + // Verify sorted by amount ASC (ORDER BY amount ASC in JDQL) + for (int i = 1; i < result.size(); i++) { + assertThat(result.get(i).getAmount()).isGreaterThanOrEqualTo(result.get(i - 1).getAmount()); + } + } + + @Test + @Order(6) + @DisplayName("#6 @Query JDQL aggregate async: countByStatusAsync") + void jdqlAggregate_async() throws Exception { + CompletionStage stage = repository.countByStatusAsync("OPEN"); + Long result = stage.toCompletableFuture().get(5, TimeUnit.SECONDS); + + assertThat(result).isEqualTo(5L); + } + + @Test + @Order(7) + @DisplayName("#7 Async with empty result set") + void async_emptyResult() throws Exception { + CompletionStage> stage = repository.findByStatusAsync("NONEXISTENT"); + List result = stage.toCompletableFuture().get(5, TimeUnit.SECONDS); + + assertThat(result).isEmpty(); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataCountFieldTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataCountFieldTest.java new file mode 100644 index 000000000..9146f5672 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataCountFieldTest.java @@ -0,0 +1,86 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for GAP-A3: COUNT(field) should exclude NULL values. + * + * Test data: orders with some null customerIds: + * - OPEN, C1, 100 + * - OPEN, null, 200 + * - OPEN, C2, 300 + * - CLOSED, C3, 400 + * - CLOSED, null, 500 + */ +@QuarkusTest +@DisplayName("Jakarta Data JDQL COUNT(field) NULL filtering") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataCountFieldTest { + + @Inject + OrderRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.clearCollection(OrderEntity.class); + + createOrder("C1", 100.0, "OPEN"); + createOrder(null, 200.0, "OPEN"); + createOrder("C2", 300.0, "OPEN"); + createOrder("C3", 400.0, "CLOSED"); + createOrder(null, 500.0, "CLOSED"); + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + @Test + @Order(1) + @DisplayName("COUNT(customerId) excludes NULLs") + void countField_excludesNulls() { + List results = repository.countNonNullCustomerByStatus(); + assertThat(results).hasSize(2); + + Map map = results.stream() + .collect(Collectors.toMap(StatusCount::status, StatusCount::count)); + // OPEN: C1 + C2 = 2 (not 3) + assertThat(map.get("OPEN")).isEqualTo(2L); + // CLOSED: C3 = 1 (not 2) + assertThat(map.get("CLOSED")).isEqualTo(1L); + } + + @Test + @Order(2) + @DisplayName("COUNT(this) still includes all rows (regression)") + void countThis_includesAll() { + List results = repository.countGroupByStatus(); + assertThat(results).hasSize(2); + + Map map = results.stream() + .collect(Collectors.toMap(StatusCount::status, StatusCount::count)); + assertThat(map.get("OPEN")).isEqualTo(3L); + assertThat(map.get("CLOSED")).isEqualTo(2L); + } + + private void createOrder(String customerId, double amount, String status) { + var order = new OrderEntity(); + order.setCustomerId(customerId); + order.setAmount(amount); + order.setStatus(status); + morphium.store(order); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataCoverageTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataCoverageTest.java new file mode 100644 index 000000000..028fbaa03 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataCoverageTest.java @@ -0,0 +1,291 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for Jakarta Data #4: Test Coverage Extension. + * Tests untested operators: LessThanEqual, Not, Between, In, NotIn, + * StartsWith, EndsWith, Like, IsNull, IsNotNull, IsTrue, IsFalse, + * OR combinator, multiple OrderBy, Stream return type. + */ +@QuarkusTest +@DisplayName("Jakarta Data Query Derivation — Coverage Extension") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataCoverageTest { + + @Inject + OrderRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.clearCollection(OrderEntity.class); + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + @Test + @Order(1) + @DisplayName("findByAmountLessThanEqual returns orders with amount <= threshold") + void findByAmountLessThanEqual() { + morphium.store(order("C1", 50, "OPEN")); + morphium.store(order("C2", 100, "OPEN")); + morphium.store(order("C3", 200, "OPEN")); + + List result = repository.findByAmountLessThanEqual(100); + + assertThat(result).hasSize(2); + assertThat(result).extracting(OrderEntity::getAmount) + .allMatch(a -> a <= 100); + } + + @Test + @Order(2) + @DisplayName("findByStatusNot excludes orders with given status and sorts by @OrderBy(amount DESC)") + void findByStatusNot() { + morphium.store(order("C1", 100, "OPEN")); + morphium.store(order("C2", 50, "CLOSED")); + morphium.store(order("C3", 200, "CLOSED")); + morphium.store(order("C4", 150, "PENDING")); + + List result = repository.findByStatusNot("OPEN"); + + assertThat(result).hasSize(3); + assertThat(result).extracting(OrderEntity::getStatus) + .allMatch(s -> !"OPEN".equals(s)); + // Verify @OrderBy(value = "amount", descending = true) on query derivation method + assertThat(result).extracting(OrderEntity::getAmount) + .containsExactly(200.0, 150.0, 50.0); + } + + @Test + @Order(3) + @DisplayName("findByAmountBetween returns orders within range") + void findByAmountBetween() { + morphium.store(order("C1", 50, "OPEN")); + morphium.store(order("C2", 100, "OPEN")); + morphium.store(order("C3", 200, "OPEN")); + + List result = repository.findByAmountBetween(80, 150); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getAmount()).isEqualTo(100); + } + + @Test + @Order(4) + @DisplayName("findByStatusIn returns orders matching any of given statuses") + void findByStatusIn() { + morphium.store(order("C1", 100, "OPEN")); + morphium.store(order("C2", 200, "CLOSED")); + morphium.store(order("C3", 50, "PENDING")); + + List result = repository.findByStatusIn(List.of("OPEN", "PENDING")); + + assertThat(result).hasSize(2); + assertThat(result).extracting(OrderEntity::getStatus) + .containsExactlyInAnyOrder("OPEN", "PENDING"); + } + + @Test + @Order(5) + @DisplayName("findByStatusNotIn excludes orders matching given statuses") + void findByStatusNotIn() { + morphium.store(order("C1", 100, "OPEN")); + morphium.store(order("C2", 200, "CLOSED")); + morphium.store(order("C3", 50, "PENDING")); + + List result = repository.findByStatusNotIn(List.of("OPEN")); + + assertThat(result).hasSize(2); + assertThat(result).extracting(OrderEntity::getStatus) + .containsExactlyInAnyOrder("CLOSED", "PENDING"); + } + + @Test + @Order(6) + @DisplayName("findByCustomerIdStartsWith matches prefix") + void findByCustomerIdStartsWith() { + morphium.store(order("C-100", 100, "OPEN")); + morphium.store(order("C-200", 200, "OPEN")); + morphium.store(order("D-300", 50, "OPEN")); + + List result = repository.findByCustomerIdStartsWith("C-"); + + assertThat(result).hasSize(2); + assertThat(result).extracting(OrderEntity::getCustomerId) + .allMatch(id -> id.startsWith("C-")); + } + + @Test + @Order(7) + @DisplayName("findByCustomerIdEndsWith matches suffix") + void findByCustomerIdEndsWith() { + morphium.store(order("abc-1", 100, "OPEN")); + morphium.store(order("def-1", 200, "OPEN")); + morphium.store(order("abc-2", 50, "OPEN")); + + List result = repository.findByCustomerIdEndsWith("-1"); + + assertThat(result).hasSize(2); + assertThat(result).extracting(OrderEntity::getCustomerId) + .allMatch(id -> id.endsWith("-1")); + } + + @Test + @Order(8) + @DisplayName("findByCustomerIdLike matches SQL wildcard patterns") + void findByCustomerIdLike() { + morphium.store(order("C-100", 100, "OPEN")); + morphium.store(order("C-200", 200, "OPEN")); + morphium.store(order("D-300", 50, "OPEN")); + + // % wildcard + List percentResult = repository.findByCustomerIdLike("C-%"); + assertThat(percentResult).hasSize(2); + + // _ wildcard (single char) + List underscoreResult = repository.findByCustomerIdLike("_-100"); + assertThat(underscoreResult).hasSize(1); + assertThat(underscoreResult.get(0).getCustomerId()).isEqualTo("C-100"); + } + + @Test + @Order(9) + @DisplayName("findByCustomerIdIsNull returns orders without customerId") + void findByCustomerIdIsNull() { + morphium.store(order("C1", 100, "OPEN")); + morphium.store(order("C2", 200, "OPEN")); + morphium.store(order(null, 50, "CLOSED")); + + List result = repository.findByCustomerIdIsNull(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getCustomerId()).isNull(); + } + + @Test + @Order(10) + @DisplayName("findByCustomerIdIsNotNull returns orders with customerId set") + void findByCustomerIdIsNotNull() { + morphium.store(order("C1", 100, "OPEN")); + morphium.store(order("C2", 200, "OPEN")); + morphium.store(order(null, 50, "CLOSED")); + + List result = repository.findByCustomerIdIsNotNull(); + + assertThat(result).hasSize(2); + assertThat(result).extracting(OrderEntity::getCustomerId) + .doesNotContainNull(); + } + + @Test + @Order(11) + @DisplayName("findByUrgentIsTrue returns only urgent orders") + void findByUrgentIsTrue() { + morphium.store(urgentOrder("C1", 100, "OPEN", true)); + morphium.store(urgentOrder("C2", 200, "OPEN", false)); + morphium.store(urgentOrder("C3", 50, "CLOSED", false)); + + List result = repository.findByUrgentIsTrue(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getCustomerId()).isEqualTo("C1"); + assertThat(result.get(0).isUrgent()).isTrue(); + } + + @Test + @Order(12) + @DisplayName("findByUrgentIsFalse returns only non-urgent orders") + void findByUrgentIsFalse() { + morphium.store(urgentOrder("C1", 100, "OPEN", true)); + morphium.store(urgentOrder("C2", 200, "OPEN", false)); + morphium.store(urgentOrder("C3", 50, "CLOSED", false)); + + List result = repository.findByUrgentIsFalse(); + + assertThat(result).hasSize(2); + assertThat(result).extracting(OrderEntity::isUrgent) + .containsOnly(false); + } + + @Test + @Order(13) + @DisplayName("findByStatusOrCustomerId combines conditions with OR") + void findByStatusOrCustomerId() { + morphium.store(order("C1", 100, "OPEN")); + morphium.store(order("C2", 200, "CLOSED")); + morphium.store(order("C1", 50, "PENDING")); + + // OPEN status OR customerId=C2 + List result = repository.findByStatusOrCustomerId("OPEN", "C2"); + + assertThat(result).hasSize(2); + assertThat(result).extracting(OrderEntity::getCustomerId) + .containsExactlyInAnyOrder("C1", "C2"); + } + + @Test + @Order(14) + @DisplayName("findByStatus with multiple OrderBy sorts by amount ASC then customerId DESC") + void findByStatus_multipleOrderBy() { + morphium.store(order("B", 100, "OPEN")); + morphium.store(order("A", 100, "OPEN")); + morphium.store(order("C", 50, "OPEN")); + + List result = repository.findByStatusOrderByAmountAscCustomerIdDesc("OPEN"); + + assertThat(result).hasSize(3); + // amount ASC: 50 first, then 100, 100 + assertThat(result.get(0).getAmount()).isEqualTo(50); + assertThat(result.get(0).getCustomerId()).isEqualTo("C"); + // among amount=100: customerId DESC → B before A + assertThat(result.get(1).getCustomerId()).isEqualTo("B"); + assertThat(result.get(2).getCustomerId()).isEqualTo("A"); + } + + @Test + @Order(15) + @DisplayName("findBy* with Stream return type returns a stream") + void findBy_streamReturnType() { + morphium.store(order("C1", 100, "OPEN")); + morphium.store(order("C2", 200, "OPEN")); + morphium.store(order("C3", 300, "OPEN")); + + try (Stream stream = repository.findByAmountGreaterThanEqualOrderByAmountAsc(100)) { + List result = stream.toList(); + + assertThat(result).hasSize(3); + // verify ordering + assertThat(result).extracting(OrderEntity::getAmount) + .containsExactly(100.0, 200.0, 300.0); + } + } + + private OrderEntity order(String customerId, double amount, String status) { + var o = new OrderEntity(); + o.setCustomerId(customerId); + o.setAmount(amount); + o.setStatus(status); + return o; + } + + private OrderEntity urgentOrder(String customerId, double amount, String status, boolean urgent) { + var o = order(customerId, amount, status); + o.setUrgent(urgent); + return o; + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataCrudTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataCrudTest.java new file mode 100644 index 000000000..e1aed5778 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataCrudTest.java @@ -0,0 +1,202 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for Jakarta Data CRUD operations via {@link ItemRepository}. + */ +@QuarkusTest +@DisplayName("Jakarta Data CRUD operations") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataCrudTest { + + @Inject + ItemRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void cleanCollection() { + morphium.clearCollection(ItemEntity.class); + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + @Test + @Order(1) + @DisplayName("repository is injectable") + void repository_isInjectable() { + assertThat(repository).isNotNull(); + } + + @Test + @Order(2) + @DisplayName("save() persists entity and assigns id") + void save_persistsEntity() { + var item = new ItemEntity(); + item.setName("Widget"); + item.setPrice(9.99); + + ItemEntity saved = repository.save(item); + + assertThat(saved).isNotNull(); + assertThat(saved.getId()).isNotNull().isNotBlank(); + } + + @Test + @Order(3) + @DisplayName("findById() returns Optional with saved entity") + void findById_returnsEntity() { + var item = new ItemEntity(); + item.setName("Gadget"); + item.setPrice(19.99); + repository.save(item); + + Optional found = repository.findById(item.getId()); + + assertThat(found).isPresent(); + assertThat(found.get().getName()).isEqualTo("Gadget"); + assertThat(found.get().getPrice()).isEqualTo(19.99); + } + + @Test + @Order(4) + @DisplayName("findById() returns empty Optional for non-existing id") + void findById_returnsEmpty() { + Optional found = repository.findById("non-existing-id"); + assertThat(found).isEmpty(); + } + + @Test + @Order(5) + @DisplayName("delete() removes entity") + void delete_removesEntity() { + var item = new ItemEntity(); + item.setName("ToDelete"); + repository.save(item); + String id = item.getId(); + + repository.delete(item); + + assertThat(repository.findById(id)).isEmpty(); + } + + @Test + @Order(6) + @DisplayName("deleteById() removes entity by id") + void deleteById_removesEntity() { + var item = new ItemEntity(); + item.setName("ToDeleteById"); + repository.save(item); + String id = item.getId(); + + repository.deleteById(id); + + assertThat(repository.findById(id)).isEmpty(); + } + + @Test + @Order(7) + @DisplayName("insert() creates new entity") + void insert_createsEntity() { + var item = new ItemEntity(); + item.setName("Inserted"); + item.setPrice(5.0); + + ItemEntity inserted = repository.insert(item); + + assertThat(inserted.getId()).isNotNull(); + assertThat(repository.findById(inserted.getId())).isPresent(); + } + + @Test + @Order(8) + @DisplayName("insertAll() creates multiple entities") + void insertAll_createsEntities() { + var a = new ItemEntity(); + a.setName("Batch-A"); + var b = new ItemEntity(); + b.setName("Batch-B"); + + List inserted = repository.insertAll(List.of(a, b)); + + assertThat(inserted).hasSize(2); + } + + @Test + @Order(9) + @DisplayName("findAll() returns all entities as Stream") + void findAll_returnsAll() { + var item1 = new ItemEntity(); + item1.setName("One"); + var item2 = new ItemEntity(); + item2.setName("Two"); + repository.save(item1); + repository.save(item2); + + List all = repository.findAll().collect(Collectors.toList()); + + assertThat(all).hasSize(2); + } + + @Test + @Order(10) + @DisplayName("update() stores changes") + void update_storesChanges() { + var item = new ItemEntity(); + item.setName("Original"); + item.setPrice(10.0); + repository.save(item); + + item.setName("Updated"); + repository.update(item); + + Optional found = repository.findById(item.getId()); + assertThat(found).isPresent(); + assertThat(found.get().getName()).isEqualTo("Updated"); + } + + @Test + @Order(11) + @DisplayName("saveAll() persists multiple entities") + void saveAll_persistsAll() { + var a = new ItemEntity(); + a.setName("SaveAll-A"); + var b = new ItemEntity(); + b.setName("SaveAll-B"); + + List saved = repository.saveAll(List.of(a, b)); + + assertThat(saved).hasSize(2); + assertThat(saved).allSatisfy(item -> + assertThat(item.getId()).isNotNull()); + } + + @Test + @Order(12) + @DisplayName("deleteAll() removes multiple entities") + void deleteAll_removesAll() { + var a = new ItemEntity(); + a.setName("DelAll-A"); + var b = new ItemEntity(); + b.setName("DelAll-B"); + repository.saveAll(List.of(a, b)); + + repository.deleteAll(List.of(a, b)); + + assertThat(repository.findAll().count()).isZero(); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataCursoredPageTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataCursoredPageTest.java new file mode 100644 index 000000000..d6ef14595 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataCursoredPageTest.java @@ -0,0 +1,210 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.data.Order; +import jakarta.data.Sort; +import jakarta.data.page.CursoredPage; +import jakarta.data.page.PageRequest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for CursoredPage (keyset/cursor-based pagination). + */ +@QuarkusTest +@DisplayName("Jakarta Data CursoredPage Pagination") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataCursoredPageTest { + + @Inject + PaginatedOrderRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.clearCollection(OrderEntity.class); + // Create 25 OPEN orders with amounts 10, 20, ..., 250 + for (int i = 1; i <= 25; i++) { + var order = new OrderEntity(); + order.setCustomerId("C" + i); + order.setAmount(i * 10.0); + order.setStatus("OPEN"); + morphium.store(order); + } + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + @Test + @org.junit.jupiter.api.Order(1) + @DisplayName("#1 First page with offset mode returns correct results") + void firstPage_offsetMode() { + PageRequest request = PageRequest.ofSize(5); + CursoredPage page = repository.findPagedByStatus("OPEN", request); + + assertThat(page.content()).hasSize(5); + assertThat(page.hasNext()).isTrue(); + assertThat(page.numberOfElements()).isEqualTo(5); + // Cursors should be available for each element + for (int i = 0; i < page.numberOfElements(); i++) { + assertThat(page.cursor(i)).isNotNull(); + } + } + + @Test + @org.junit.jupiter.api.Order(2) + @DisplayName("#2 Next page via CURSOR_NEXT returns subsequent results") + void nextPage_cursorNext() { + PageRequest request = PageRequest.ofSize(5); + CursoredPage page1 = repository.findPagedByStatus("OPEN", request); + + PageRequest nextRequest = page1.nextPageRequest(); + assertThat(nextRequest).isNotNull(); + + CursoredPage page2 = repository.findPagedByStatus("OPEN", nextRequest); + assertThat(page2.content()).hasSize(5); + + // No duplicates between page1 and page2 + List page1Ids = page1.content().stream().map(OrderEntity::getId).toList(); + List page2Ids = page2.content().stream().map(OrderEntity::getId).toList(); + assertThat(page2Ids).doesNotContainAnyElementsOf(page1Ids); + + // Page2 amounts should be higher than page1 amounts (sorted by amount ASC) + double lastAmountPage1 = page1.content().get(page1.numberOfElements() - 1).getAmount(); + double firstAmountPage2 = page2.content().get(0).getAmount(); + assertThat(firstAmountPage2).isGreaterThan(lastAmountPage1); + } + + @Test + @org.junit.jupiter.api.Order(3) + @DisplayName("#3 Previous page via CURSOR_PREVIOUS returns original results") + void previousPage_cursorPrevious() { + PageRequest request = PageRequest.ofSize(5); + CursoredPage page1 = repository.findPagedByStatus("OPEN", request); + CursoredPage page2 = repository.findPagedByStatus("OPEN", page1.nextPageRequest()); + + PageRequest prevRequest = page2.previousPageRequest(); + assertThat(prevRequest).isNotNull(); + + CursoredPage prevPage = repository.findPagedByStatus("OPEN", prevRequest); + assertThat(prevPage.content()).hasSize(5); + + // Previous page should have the same IDs as page1 + List page1Ids = page1.content().stream().map(OrderEntity::getId).toList(); + List prevPageIds = prevPage.content().stream().map(OrderEntity::getId).toList(); + assertThat(prevPageIds).containsExactlyElementsOf(page1Ids); + } + + @Test + @org.junit.jupiter.api.Order(4) + @DisplayName("#4 Last page has hasNext=false") + void lastPage_hasNextFalse() { + PageRequest request = PageRequest.ofSize(5); + CursoredPage page = repository.findPagedByStatus("OPEN", request); + + List allCollected = new ArrayList<>(page.content()); + int pages = 1; + while (page.hasNext()) { + page = repository.findPagedByStatus("OPEN", page.nextPageRequest()); + allCollected.addAll(page.content()); + pages++; + } + + assertThat(page.hasNext()).isFalse(); + assertThat(allCollected).hasSize(25); + assertThat(pages).isEqualTo(5); // 25 items / 5 per page + } + + @Test + @org.junit.jupiter.api.Order(5) + @DisplayName("#5 Cursor values match sort field values") + void cursorValues_matchSortFields() { + PageRequest request = PageRequest.ofSize(5); + CursoredPage page = repository.findPagedByStatus("OPEN", request); + + for (int i = 0; i < page.numberOfElements(); i++) { + PageRequest.Cursor cursor = page.cursor(i); + OrderEntity entity = page.content().get(i); + // Cursor should have 2 elements (amount, id) + assertThat(cursor.size()).isEqualTo(2); + assertThat(cursor.get(0)).isEqualTo(entity.getAmount()); + assertThat(cursor.get(1)).isEqualTo(entity.getId()); + } + } + + @Test + @org.junit.jupiter.api.Order(6) + @DisplayName("#6 @Query with CursoredPage works") + void queryAnnotation_cursoredPage() { + PageRequest request = PageRequest.ofSize(5); + CursoredPage page = repository.queryPagedByStatus("OPEN", request); + + assertThat(page.content()).hasSize(5); + assertThat(page.hasNext()).isTrue(); + + // Navigate to next page + CursoredPage page2 = repository.queryPagedByStatus("OPEN", page.nextPageRequest()); + assertThat(page2.content()).hasSize(5); + + // No duplicates + List page1Ids = page.content().stream().map(OrderEntity::getId).toList(); + List page2Ids = page2.content().stream().map(OrderEntity::getId).toList(); + assertThat(page2Ids).doesNotContainAnyElementsOf(page1Ids); + } + + @Test + @org.junit.jupiter.api.Order(7) + @DisplayName("#7 findAll with CursoredPage works") + void findAll_cursoredPage() { + Order order = Order.by(Sort.asc("amount"), Sort.asc("id")); + PageRequest request = PageRequest.ofSize(10); + CursoredPage page = repository.findAll(request, order); + + assertThat(page.content()).hasSize(10); + assertThat(page.hasNext()).isTrue(); + + CursoredPage page2 = repository.findAll(page.nextPageRequest(), order); + assertThat(page2.content()).hasSize(10); + + // Verify ordering + double lastAmount = page.content().get(page.numberOfElements() - 1).getAmount(); + double firstAmountPage2 = page2.content().get(0).getAmount(); + assertThat(firstAmountPage2).isGreaterThan(lastAmount); + } + + @Test + @org.junit.jupiter.api.Order(8) + @DisplayName("#8 withTotal returns correct totalElements") + void withTotal_countWorks() { + PageRequest request = PageRequest.ofSize(5).withTotal(); + CursoredPage page = repository.findPagedByStatus("OPEN", request); + + assertThat(page.hasTotals()).isTrue(); + assertThat(page.totalElements()).isEqualTo(25); + assertThat(page.totalPages()).isEqualTo(5); + } + + @Test + @org.junit.jupiter.api.Order(9) + @DisplayName("#9 Empty result returns empty page with hasNext=false") + void emptyResult_noCursors() { + PageRequest request = PageRequest.ofSize(5); + CursoredPage page = repository.findPagedByStatus("NONEXISTENT", request); + + assertThat(page.content()).isEmpty(); + assertThat(page.hasNext()).isFalse(); + assertThat(page.numberOfElements()).isEqualTo(0); + assertThat(page.hasContent()).isFalse(); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataDeleteTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataDeleteTest.java new file mode 100644 index 000000000..3e214de9b --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataDeleteTest.java @@ -0,0 +1,165 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for Jakarta Data #3: deleteAll() no-arg + deleteBy* query derivation. + */ +@QuarkusTest +@DisplayName("Jakarta Data Delete — deleteAll() no-arg + deleteBy* derivation") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataDeleteTest { + + @Inject + OrderRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.clearCollection(OrderEntity.class); + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + @Test + @Order(1) + @DisplayName("deleteByStatus returns count of deleted entities") + void deleteByStatus_returnsCount() { + morphium.store(order("C1", 100, "OPEN")); + morphium.store(order("C2", 200, "OPEN")); + morphium.store(order("C3", 300, "OPEN")); + morphium.store(order("C4", 400, "CLOSED")); + morphium.store(order("C5", 500, "CLOSED")); + + long deleted = repository.deleteByStatus("OPEN"); + + assertThat(deleted).isEqualTo(3); + assertThat(repository.findByStatus("OPEN")).isEmpty(); + assertThat(repository.findByStatus("CLOSED")).hasSize(2); + } + + @Test + @Order(2) + @DisplayName("deleteByStatus with no match returns zero") + void deleteByStatus_noMatch_returnsZero() { + morphium.store(order("C1", 100, "OPEN")); + morphium.store(order("C2", 200, "OPEN")); + + long deleted = repository.deleteByStatus("CLOSED"); + + assertThat(deleted).isZero(); + assertThat(repository.countByStatus("OPEN")).isEqualTo(2); + } + + @Test + @Order(3) + @DisplayName("deleteByAmountLessThan (void return) deletes matching entities") + void deleteByAmountLessThan_void() { + morphium.store(order("C1", 50, "OPEN")); + morphium.store(order("C2", 100, "OPEN")); + morphium.store(order("C3", 200, "OPEN")); + + repository.deleteByAmountLessThan(100.0); + + List remaining = repository.findByStatus("OPEN"); + assertThat(remaining).hasSize(2); + assertThat(remaining).extracting(OrderEntity::getAmount) + .containsExactlyInAnyOrder(100.0, 200.0); + } + + @Test + @Order(4) + @DisplayName("deleteByCustomerId (boolean return) returns true when entities deleted") + void deleteByCustomerId_boolean_true() { + morphium.store(order("C1", 100, "OPEN")); + + boolean deleted = repository.deleteByCustomerId("C1"); + + assertThat(deleted).isTrue(); + assertThat(repository.findByStatus("OPEN")).isEmpty(); + } + + @Test + @Order(5) + @DisplayName("deleteByCustomerId (boolean return) returns false when nothing to delete") + void deleteByCustomerId_boolean_false() { + boolean deleted = repository.deleteByCustomerId("C1"); + + assertThat(deleted).isFalse(); + } + + @Test + @Order(6) + @DisplayName("deleteAll() no-arg clears entire collection") + void deleteAll_noArg_clearsCollection() { + morphium.store(order("C1", 100, "OPEN")); + morphium.store(order("C2", 200, "OPEN")); + morphium.store(order("C3", 300, "CLOSED")); + morphium.store(order("C4", 400, "CLOSED")); + morphium.store(order("C5", 500, "PENDING")); + + repository.deleteAll(); + + assertThat(repository.findAll().toList()).isEmpty(); + } + + @Test + @Order(7) + @DisplayName("deleteAll() no-arg on empty collection does not throw") + void deleteAll_noArg_emptyCollection() { + repository.deleteAll(); + + assertThat(repository.findAll().toList()).isEmpty(); + } + + @Test + @Order(8) + @DisplayName("silent data loss fix: remove(entity) with an entity-typed @Delete parameter actually deletes that document") + void removeByEntityParameter_deletesTheDocument() { + OrderEntity toDelete = order("C1", 100, "OPEN"); + morphium.store(toDelete); + morphium.store(order("C2", 200, "OPEN")); + morphium.store(order("C3", 300, "CLOSED")); + + // This is the check the original bug report needed: the document count BEFORE and AFTER + // the call. Before the fix, the entity parameter was misclassified as a @By-condition + // (falling back to the parameter name), building a bogus query such as + // {order: } that never matches anything in MongoDB -- so + // query.delete() silently removed zero documents while the method still returned + // normally. Asserting only "no exception was thrown" would NOT have caught that; the + // count comparison is what actually detects the data loss. + long countBefore = morphium.createQueryFor(OrderEntity.class).countAll(); + assertThat(countBefore).isEqualTo(3); + + repository.remove(toDelete); + + long countAfter = morphium.createQueryFor(OrderEntity.class).countAll(); + assertThat(countAfter).isEqualTo(countBefore - 1); + assertThat(morphium.createQueryFor(OrderEntity.class) + .f("customerId").eq("C1").countAll()).isZero(); + assertThat(morphium.createQueryFor(OrderEntity.class) + .f("customerId").eq("C2").countAll()).isEqualTo(1); + assertThat(morphium.createQueryFor(OrderEntity.class) + .f("customerId").eq("C3").countAll()).isEqualTo(1); + } + + private OrderEntity order(String customerId, double amount, String status) { + var o = new OrderEntity(); + o.setCustomerId(customerId); + o.setAmount(amount); + o.setStatus(status); + return o; + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataExceptionTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataExceptionTest.java new file mode 100644 index 000000000..f8bc7e44f --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataExceptionTest.java @@ -0,0 +1,170 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.data.exceptions.EmptyResultException; +import jakarta.data.exceptions.NonUniqueResultException; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Integration tests verifying that Jakarta Data standard exceptions are thrown + * when single-result repository methods encounter no result or multiple results. + */ +@QuarkusTest +@DisplayName("Jakarta Data Standard Exceptions") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataExceptionTest { + + @Inject + OrderRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.clearCollection(OrderEntity.class); + } + + // --- Query derivation: T return type --- + + @Test + @Order(1) + @DisplayName("findByX returning T throws EmptyResultException when no result") + void findSingle_noResult_throwsEmptyResult() { + assertThatThrownBy(() -> repository.findByCustomerId("nonexistent")) + .isInstanceOf(EmptyResultException.class); + } + + @Test + @Order(2) + @DisplayName("findByX returning T throws NonUniqueResultException when multiple results") + void findSingle_multipleResults_throwsNonUnique() { + // Store two orders with same customerId + var o1 = new OrderEntity(); + o1.setCustomerId("DUPE"); + o1.setAmount(100.0); + o1.setStatus("OPEN"); + morphium.store(o1); + + var o2 = new OrderEntity(); + o2.setCustomerId("DUPE"); + o2.setAmount(200.0); + o2.setStatus("CLOSED"); + morphium.store(o2); + + assertThatThrownBy(() -> repository.findByCustomerId("DUPE")) + .isInstanceOf(NonUniqueResultException.class); + } + + @Test + @Order(3) + @DisplayName("findByX returning T returns entity when exactly one result") + void findSingle_exactlyOne_returnsEntity() { + var o = new OrderEntity(); + o.setCustomerId("UNIQUE"); + o.setAmount(42.0); + o.setStatus("OPEN"); + morphium.store(o); + + OrderEntity result = repository.findByCustomerId("UNIQUE"); + assertThat(result).isNotNull(); + assertThat(result.getCustomerId()).isEqualTo("UNIQUE"); + } + + // --- Query derivation: Optional return type --- + + @Test + @Order(4) + @DisplayName("findOptionalByX returns Optional.empty() when no result (no exception)") + void findOptional_noResult_returnsEmpty() { + Optional result = repository.findOptionalByCustomerId("nonexistent"); + assertThat(result).isEmpty(); + } + + @Test + @Order(5) + @DisplayName("findOptionalByX throws NonUniqueResultException when multiple results") + void findOptional_multipleResults_throwsNonUnique() { + var o1 = new OrderEntity(); + o1.setCustomerId("DUPE2"); + o1.setAmount(10.0); + o1.setStatus("OPEN"); + morphium.store(o1); + + var o2 = new OrderEntity(); + o2.setCustomerId("DUPE2"); + o2.setAmount(20.0); + o2.setStatus("OPEN"); + morphium.store(o2); + + assertThatThrownBy(() -> repository.findOptionalByCustomerId("DUPE2")) + .isInstanceOf(NonUniqueResultException.class); + } + + // --- JDQL @Query: T return type --- + + @Test + @Order(6) + @DisplayName("@Query returning T throws EmptyResultException when no result") + void jdql_noResult_throwsEmptyResult() { + assertThatThrownBy(() -> repository.queryByCustomerId("nonexistent")) + .isInstanceOf(EmptyResultException.class); + } + + @Test + @Order(7) + @DisplayName("@Query returning T throws NonUniqueResultException when multiple results") + void jdql_multipleResults_throwsNonUnique() { + var o1 = new OrderEntity(); + o1.setCustomerId("JDUPE"); + o1.setAmount(10.0); + o1.setStatus("OPEN"); + morphium.store(o1); + + var o2 = new OrderEntity(); + o2.setCustomerId("JDUPE"); + o2.setAmount(20.0); + o2.setStatus("OPEN"); + morphium.store(o2); + + assertThatThrownBy(() -> repository.queryByCustomerId("JDUPE")) + .isInstanceOf(NonUniqueResultException.class); + } + + // --- JDQL @Query: Optional return type --- + + @Test + @Order(8) + @DisplayName("@Query returning Optional returns empty when no result") + void jdqlOptional_noResult_returnsEmpty() { + Optional result = repository.queryOptionalByCustomerId("nonexistent"); + assertThat(result).isEmpty(); + } + + @Test + @Order(9) + @DisplayName("@Query returning Optional throws NonUniqueResultException when multiple results") + void jdqlOptional_multipleResults_throwsNonUnique() { + var o1 = new OrderEntity(); + o1.setCustomerId("JDUPE2"); + o1.setAmount(10.0); + o1.setStatus("OPEN"); + morphium.store(o1); + + var o2 = new OrderEntity(); + o2.setCustomerId("JDUPE2"); + o2.setAmount(20.0); + o2.setStatus("OPEN"); + morphium.store(o2); + + assertThatThrownBy(() -> repository.queryOptionalByCustomerId("JDUPE2")) + .isInstanceOf(NonUniqueResultException.class); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataGroupByPageTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataGroupByPageTest.java new file mode 100644 index 000000000..37955d004 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataGroupByPageTest.java @@ -0,0 +1,116 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.data.page.Page; +import jakarta.data.page.PageRequest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for GAP-A8: Pagination with GROUP BY queries. + * + * Test data: 8 orders across 2 statuses (CLOSED, OPEN — sorted ASC). + * - OPEN: count=5 + * - CLOSED: count=3 + */ +@QuarkusTest +@DisplayName("Jakarta Data JDQL GROUP BY Pagination") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataGroupByPageTest { + + @Inject + OrderRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.clearCollection(OrderEntity.class); + + createOrder("C1", 100.0, "OPEN"); + createOrder("C1", 200.0, "OPEN"); + createOrder("C2", 300.0, "OPEN"); + createOrder("C3", 400.0, "OPEN"); + createOrder("C3", 500.0, "OPEN"); + createOrder("C1", 600.0, "CLOSED"); + createOrder("C2", 700.0, "CLOSED"); + createOrder("C2", 800.0, "CLOSED"); + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + @Test + @Order(1) + @DisplayName("First page of grouped results") + void firstPage() { + Page page = repository.countGroupByStatusPaged( + PageRequest.ofPage(1, 1, true)); + + assertThat(page.content()).hasSize(1); + // ORDER BY status ASC → CLOSED first + assertThat(page.content().get(0).status()).isEqualTo("CLOSED"); + assertThat(page.totalElements()).isEqualTo(2); + assertThat(page.hasNext()).isTrue(); + } + + @Test + @Order(2) + @DisplayName("Second page of grouped results") + void secondPage() { + Page page = repository.countGroupByStatusPaged( + PageRequest.ofPage(2, 1, true)); + + assertThat(page.content()).hasSize(1); + assertThat(page.content().get(0).status()).isEqualTo("OPEN"); + assertThat(page.totalElements()).isEqualTo(2); + assertThat(page.hasNext()).isFalse(); + } + + @Test + @Order(3) + @DisplayName("Beyond last page → empty") + void beyondLast() { + Page page = repository.countGroupByStatusPaged( + PageRequest.ofPage(3, 1, true)); + + assertThat(page.content()).isEmpty(); + assertThat(page.totalElements()).isEqualTo(2); + } + + @Test + @Order(4) + @DisplayName("All results in one page") + void allInOnePage() { + Page page = repository.countGroupByStatusPaged( + PageRequest.ofPage(1, 10, true)); + + assertThat(page.content()).hasSize(2); + assertThat(page.totalElements()).isEqualTo(2); + } + + @Test + @Order(5) + @DisplayName("No total requested → hasTotals false") + void noTotalRequested() { + Page page = repository.countGroupByStatusPaged( + PageRequest.ofPage(1, 1, false)); + + assertThat(page.content()).hasSize(1); + assertThat(page.hasTotals()).isFalse(); + } + + private void createOrder(String customerId, double amount, String status) { + var order = new OrderEntity(); + order.setCustomerId(customerId); + order.setAmount(amount); + order.setStatus(status); + morphium.store(order); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataGroupByTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataGroupByTest.java new file mode 100644 index 000000000..681900a17 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataGroupByTest.java @@ -0,0 +1,128 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for Jakarta Data #8v2: JDQL GROUP BY with Record mapping. + * Tests single-field GROUP BY with COUNT, SUM, WHERE, and ORDER BY. + */ +@QuarkusTest +@DisplayName("Jakarta Data JDQL GROUP BY") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataGroupByTest { + + @Inject + OrderRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.clearCollection(OrderEntity.class); + + // 5 OPEN orders: amounts 100, 200, 300, 400, 500 (total=1500) + for (int i = 1; i <= 5; i++) { + createOrder("C" + i, i * 100.0, "OPEN"); + } + // 3 CLOSED orders: amounts 600, 700, 800 (total=2100) + for (int i = 6; i <= 8; i++) { + createOrder("C" + i, i * 100.0, "CLOSED"); + } + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + @Test + @Order(1) + @DisplayName("GROUP BY status → COUNT(this)") + void groupBy_count() { + List results = repository.countGroupByStatus(); + assertThat(results).hasSize(2); + + Map map = results.stream() + .collect(Collectors.toMap(StatusCount::status, StatusCount::count)); + assertThat(map).containsEntry("OPEN", 5L); + assertThat(map).containsEntry("CLOSED", 3L); + } + + @Test + @Order(2) + @DisplayName("GROUP BY status → COUNT(this), SUM(amount)") + void groupBy_countAndSum() { + List results = repository.statsByStatus(); + assertThat(results).hasSize(2); + + Map map = results.stream() + .collect(Collectors.toMap(StatusStats::status, s -> s)); + assertThat(map.get("OPEN").count()).isEqualTo(5L); + assertThat(map.get("OPEN").totalAmount()).isEqualTo(1500.0); + assertThat(map.get("CLOSED").count()).isEqualTo(3L); + assertThat(map.get("CLOSED").totalAmount()).isEqualTo(2100.0); + } + + @Test + @Order(3) + @DisplayName("GROUP BY with WHERE filter") + void groupBy_withWhere() { + // Only CLOSED orders have amount > 500 (600, 700, 800) + List results = repository.statsByStatusFiltered(500.0); + assertThat(results).hasSize(1); + assertThat(results.get(0).status()).isEqualTo("CLOSED"); + assertThat(results.get(0).count()).isEqualTo(3L); + assertThat(results.get(0).totalAmount()).isEqualTo(2100.0); + } + + @Test + @Order(4) + @DisplayName("GROUP BY with ORDER BY COUNT(this) DESC") + void groupBy_orderByCount() { + List results = repository.countGroupByStatusOrderByCount(); + assertThat(results).hasSize(2); + // OPEN (5) should come first (DESC), CLOSED (3) second + assertThat(results.get(0).status()).isEqualTo("OPEN"); + assertThat(results.get(0).count()).isEqualTo(5L); + assertThat(results.get(1).status()).isEqualTo("CLOSED"); + assertThat(results.get(1).count()).isEqualTo(3L); + } + + @Test + @Order(5) + @DisplayName("GROUP BY with ORDER BY field ASC") + void groupBy_orderByField() { + List results = repository.statsByStatusFiltered(0.0); + assertThat(results).hasSize(2); + // ORDER BY status ASC: CLOSED first, OPEN second + assertThat(results.get(0).status()).isEqualTo("CLOSED"); + assertThat(results.get(1).status()).isEqualTo("OPEN"); + } + + @Test + @Order(6) + @DisplayName("GROUP BY with no matching results → empty list") + void groupBy_noResults() { + morphium.clearCollection(OrderEntity.class); + List results = repository.countGroupByStatus(); + assertThat(results).isEmpty(); + } + + private void createOrder(String customerId, double amount, String status) { + var order = new OrderEntity(); + order.setCustomerId(customerId); + order.setAmount(amount); + order.setStatus(status); + morphium.store(order); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataGroupByV3Test.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataGroupByV3Test.java new file mode 100644 index 000000000..bedb2d8b6 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataGroupByV3Test.java @@ -0,0 +1,191 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for Jakarta Data #8v3: multi-field GROUP BY + GAP-A2 HAVING. + * + * Test data: 8 orders across 2 statuses x 3 customers: + * - OPEN, C1: 100, 200 (count=2, sum=300) + * - OPEN, C2: 300 (count=1, sum=300) + * - OPEN, C3: 400, 500 (count=2, sum=900) + * - CLOSED, C1: 600 (count=1, sum=600) + * - CLOSED, C2: 700, 800 (count=2, sum=1500) + * Totals: OPEN=5/1500.0, CLOSED=3/2100.0 + */ +@QuarkusTest +@DisplayName("Jakarta Data JDQL GROUP BY v3 + HAVING") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataGroupByV3Test { + + @Inject + OrderRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.clearCollection(OrderEntity.class); + + // OPEN, C1: 100, 200 + createOrder("C1", 100.0, "OPEN"); + createOrder("C1", 200.0, "OPEN"); + // OPEN, C2: 300 + createOrder("C2", 300.0, "OPEN"); + // OPEN, C3: 400, 500 + createOrder("C3", 400.0, "OPEN"); + createOrder("C3", 500.0, "OPEN"); + // CLOSED, C1: 600 + createOrder("C1", 600.0, "CLOSED"); + // CLOSED, C2: 700, 800 + createOrder("C2", 700.0, "CLOSED"); + createOrder("C2", 800.0, "CLOSED"); + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + // --- Multi-field GROUP BY --- + + @Test + @Order(1) + @DisplayName("Multi-field GROUP BY → 5 groups") + void multiGroupBy_count() { + List results = repository.countByStatusAndCustomer(); + assertThat(results).hasSize(5); + + Map map = results.stream() + .collect(Collectors.toMap( + r -> r.status() + ":" + r.customerId(), + StatusCustomerCount::count)); + assertThat(map).containsEntry("OPEN:C1", 2L); + assertThat(map).containsEntry("OPEN:C2", 1L); + assertThat(map).containsEntry("OPEN:C3", 2L); + assertThat(map).containsEntry("CLOSED:C1", 1L); + assertThat(map).containsEntry("CLOSED:C2", 2L); + } + + @Test + @Order(2) + @DisplayName("Multi-field GROUP BY sorted by status ASC, customerId ASC") + void multiGroupBy_sorted() { + List results = repository.countByStatusAndCustomerSorted(); + assertThat(results).hasSize(5); + // CLOSED:C1, CLOSED:C2, OPEN:C1, OPEN:C2, OPEN:C3 + assertThat(results.get(0).status()).isEqualTo("CLOSED"); + assertThat(results.get(0).customerId()).isEqualTo("C1"); + assertThat(results.get(results.size() - 1).status()).isEqualTo("OPEN"); + assertThat(results.get(results.size() - 1).customerId()).isEqualTo("C3"); + } + + @Test + @Order(3) + @DisplayName("Multi-field GROUP BY with WHERE filter") + void multiGroupBy_filtered() { + // amount > 500: only CLOSED orders (600, 700, 800) + List results = repository.countByStatusAndCustomerFiltered(500.0); + assertThat(results).hasSize(2); + + Map map = results.stream() + .collect(Collectors.toMap( + r -> r.status() + ":" + r.customerId(), + StatusCustomerCount::count)); + assertThat(map).containsEntry("CLOSED:C1", 1L); + assertThat(map).containsEntry("CLOSED:C2", 2L); + } + + @Test + @Order(4) + @DisplayName("Single-field GROUP BY still works (regression)") + void singleGroupBy_stillWorks() { + List results = repository.countGroupByStatus(); + assertThat(results).hasSize(2); + + Map map = results.stream() + .collect(Collectors.toMap(StatusCount::status, StatusCount::count)); + assertThat(map).containsEntry("OPEN", 5L); + assertThat(map).containsEntry("CLOSED", 3L); + } + + // --- HAVING --- + + @Test + @Order(5) + @DisplayName("HAVING COUNT(this) > :minCount → filters groups") + void having_countGreaterThan() { + // minCount=3: only OPEN (5) passes, CLOSED (3) fails + List results = repository.statusesWithMinCount(3L); + assertThat(results).hasSize(1); + assertThat(results.get(0).status()).isEqualTo("OPEN"); + assertThat(results.get(0).count()).isEqualTo(5L); + } + + @Test + @Order(6) + @DisplayName("HAVING COUNT(this) > 0 → all groups pass") + void having_countAll() { + List results = repository.statusesWithMinCount(0L); + assertThat(results).hasSize(2); + } + + @Test + @Order(7) + @DisplayName("HAVING COUNT(this) > 10 → no groups pass → empty list") + void having_countNone() { + List results = repository.statusesWithMinCount(10L); + assertThat(results).isEmpty(); + } + + @Test + @Order(8) + @DisplayName("HAVING SUM(amount) >= :minTotal ORDER BY SUM(amount) DESC") + void having_sumWithOrderBy() { + // minTotal=2000: only CLOSED (2100) passes, OPEN (1500) fails + List results = repository.statusesWithMinTotal(2000.0); + assertThat(results).hasSize(1); + assertThat(results.get(0).status()).isEqualTo("CLOSED"); + assertThat(results.get(0).totalAmount()).isEqualTo(2100.0); + } + + @Test + @Order(9) + @DisplayName("HAVING with numeric literal: COUNT(this) >= 5") + void having_numericLiteral() { + List results = repository.statusesWithAtLeast5(); + assertThat(results).hasSize(1); + assertThat(results.get(0).status()).isEqualTo("OPEN"); + assertThat(results.get(0).count()).isEqualTo(5L); + } + + @Test + @Order(10) + @DisplayName("HAVING with multiple AND conditions") + void having_multipleConditions() { + // COUNT > 2 AND SUM >= 2000: only CLOSED (count=3, sum=2100) passes + List results = repository.statusesWithMultipleHaving(2L, 2000.0); + assertThat(results).hasSize(1); + assertThat(results.get(0).status()).isEqualTo("CLOSED"); + assertThat(results.get(0).count()).isEqualTo(3L); + assertThat(results.get(0).totalAmount()).isEqualTo(2100.0); + } + + private void createOrder(String customerId, double amount, String status) { + var order = new OrderEntity(); + order.setCustomerId(customerId); + order.setAmount(amount); + order.setStatus(status); + morphium.store(order); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataHavingOrTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataHavingOrTest.java new file mode 100644 index 000000000..d94d19768 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataHavingOrTest.java @@ -0,0 +1,89 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for HAVING OR support. + * + * Test data: 8 orders across 2 statuses: + * - OPEN: count=5, sum=1500.0 + * - CLOSED: count=3, sum=2100.0 + */ +@QuarkusTest +@DisplayName("Jakarta Data JDQL HAVING OR") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataHavingOrTest { + + @Inject + OrderRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.clearCollection(OrderEntity.class); + + createOrder("C1", 100.0, "OPEN"); + createOrder("C1", 200.0, "OPEN"); + createOrder("C2", 300.0, "OPEN"); + createOrder("C3", 400.0, "OPEN"); + createOrder("C3", 500.0, "OPEN"); + createOrder("C1", 600.0, "CLOSED"); + createOrder("C2", 700.0, "CLOSED"); + createOrder("C2", 800.0, "CLOSED"); + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + @Test + @Order(1) + @DisplayName("HAVING OR: both conditions match different groups") + void havingOr_bothMatch() { + // COUNT > 4 matches OPEN (5), SUM >= 2000 matches CLOSED (2100) + List results = repository.statusesWithCountOrTotal(4L, 2000.0); + assertThat(results).hasSize(2); + + Map countMap = results.stream() + .collect(Collectors.toMap(StatusStats::status, StatusStats::count)); + assertThat(countMap).containsKeys("OPEN", "CLOSED"); + } + + @Test + @Order(2) + @DisplayName("HAVING OR: only one condition matches") + void havingOr_oneMatches() { + // COUNT > 10 matches neither, SUM >= 2000 matches CLOSED only + List results = repository.statusesWithCountOrTotal(10L, 2000.0); + assertThat(results).hasSize(1); + assertThat(results.get(0).status()).isEqualTo("CLOSED"); + } + + @Test + @Order(3) + @DisplayName("HAVING OR: no condition matches") + void havingOr_noneMatch() { + List results = repository.statusesWithCountOrTotal(10L, 5000.0); + assertThat(results).isEmpty(); + } + + private void createOrder(String customerId, double amount, String status) { + var order = new OrderEntity(); + order.setCustomerId(customerId); + order.setAmount(amount); + order.setStatus(status); + morphium.store(order); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataJdqlEnhancedTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataJdqlEnhancedTest.java new file mode 100644 index 000000000..c02a38b3a --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataJdqlEnhancedTest.java @@ -0,0 +1,154 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for JDQL string literals and NOT operator (#10). + */ +@QuarkusTest +@DisplayName("Jakarta Data JDQL String Literals + NOT Operator") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataJdqlEnhancedTest { + + @Inject + OrderRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.clearCollection(OrderEntity.class); + + // 3 OPEN orders + for (int i = 1; i <= 3; i++) { + var order = new OrderEntity(); + order.setCustomerId("CUST-" + i); + order.setAmount(i * 100.0); + order.setStatus("OPEN"); + order.setUrgent(i == 3); // only #3 is urgent + morphium.store(order); + } + + // 3 CLOSED orders + for (int i = 4; i <= 6; i++) { + var order = new OrderEntity(); + order.setCustomerId("CUST-" + i); + order.setAmount(i * 100.0); + order.setStatus("CLOSED"); + order.setUrgent(false); + morphium.store(order); + } + + // 2 CANCELLED orders + for (int i = 7; i <= 8; i++) { + var order = new OrderEntity(); + order.setCustomerId("CUST-" + i); + order.setAmount(i * 100.0); + order.setStatus("CANCELLED"); + order.setUrgent(false); + morphium.store(order); + } + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + // --- String Literals --- + + @Test + @Order(1) + @DisplayName("#1 String literal: WHERE status = 'OPEN'") + void stringLiteral_basic() { + List result = repository.queryByStringLiteral(); + assertThat(result).hasSize(3); + assertThat(result).allMatch(o -> "OPEN".equals(o.getStatus())); + // Verify sorted by amount ASC + for (int i = 1; i < result.size(); i++) { + assertThat(result.get(i).getAmount()).isGreaterThanOrEqualTo(result.get(i - 1).getAmount()); + } + } + + @Test + @Order(2) + @DisplayName("#2 String literal mixed with named param") + void stringLiteral_mixedWithParam() { + List result = repository.queryByStringLiteralAndParam(150.0); + assertThat(result).hasSize(2); + assertThat(result).allMatch(o -> "OPEN".equals(o.getStatus()) && o.getAmount() > 150.0); + } + + @Test + @Order(3) + @DisplayName("#3 String literal in aggregate: COUNT(this) WHERE status = 'OPEN'") + void stringLiteral_aggregate() { + long count = repository.countOpenLiteral(); + assertThat(count).isEqualTo(3L); + } + + // --- NOT Operator --- + + @Test + @Order(4) + @DisplayName("#4 NOT with param: WHERE NOT status = :status") + void not_withParam() { + List result = repository.queryNotByStatus("OPEN"); + assertThat(result).hasSize(5); + assertThat(result).noneMatch(o -> "OPEN".equals(o.getStatus())); + } + + @Test + @Order(5) + @DisplayName("#5 NOT with string literal: WHERE NOT status = 'CANCELLED'") + void not_withStringLiteral() { + List result = repository.queryNotCancelled(); + assertThat(result).hasSize(6); + assertThat(result).noneMatch(o -> "CANCELLED".equals(o.getStatus())); + } + + @Test + @Order(6) + @DisplayName("#6 NOT combined with AND: WHERE status = :s AND NOT urgent = true") + void not_combinedWithAnd() { + List result = repository.queryByStatusNotUrgent("OPEN"); + assertThat(result).hasSize(2); + assertThat(result).allMatch(o -> "OPEN".equals(o.getStatus()) && !o.isUrgent()); + } + + @Test + @Order(7) + @DisplayName("#7 NOT with comparison: WHERE NOT amount > :max") + void not_comparison() { + List result = repository.queryNotAmountGreaterThan(400.0); + // amount NOT > 400 → amount <= 400 → 100, 200, 300, 400 + assertThat(result).hasSize(4); + assertThat(result).allMatch(o -> o.getAmount() <= 400.0); + } + + @Test + @Order(8) + @DisplayName("#8 NOT IN: WHERE NOT status IN :statuses") + void not_in() { + List result = repository.queryNotInStatuses(List.of("OPEN", "CLOSED")); + assertThat(result).hasSize(2); + assertThat(result).allMatch(o -> "CANCELLED".equals(o.getStatus())); + } + + @Test + @Order(9) + @DisplayName("#9 NOT LIKE: WHERE NOT status LIKE :pattern") + void not_like() { + List result = repository.queryNotLike("OPEN%"); + assertThat(result).hasSize(5); + assertThat(result).noneMatch(o -> o.getStatus().startsWith("OPEN")); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataJdqlTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataJdqlTest.java new file mode 100644 index 000000000..a0774025f --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataJdqlTest.java @@ -0,0 +1,147 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for Jakarta Data Phase 5: @Query with JDQL. + */ +@QuarkusTest +@DisplayName("Jakarta Data @Query / JDQL") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataJdqlTest { + + @Inject + OrderRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.clearCollection(OrderEntity.class); + + createOrder("C1", 100.0, "OPEN"); + createOrder("C2", 250.0, "OPEN"); + createOrder("C3", 50.0, "CLOSED"); + createOrder("C4", 300.0, "CLOSED"); + createOrder("C5", 150.0, "PENDING"); + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + @Test + @Order(1) + @DisplayName("@Query WHERE status = :status ORDER BY amount") + void queryByStatus() { + List open = repository.queryByStatus("OPEN"); + + assertThat(open).hasSize(2); + assertThat(open).allSatisfy(o -> assertThat(o.getStatus()).isEqualTo("OPEN")); + // Should be sorted by amount ASC + assertThat(open.get(0).getAmount()).isEqualTo(100.0); + assertThat(open.get(1).getAmount()).isEqualTo(250.0); + } + + @Test + @Order(2) + @DisplayName("@Query WHERE status AND amount > :min (multiple params)") + void queryByStatusAndMinAmount() { + List result = repository.queryByStatusAndMinAmount("OPEN", 150.0); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getAmount()).isEqualTo(250.0); + } + + @Test + @Order(3) + @DisplayName("@Query WHERE amount BETWEEN :min AND :max ORDER BY amount DESC") + void queryByAmountRange() { + List result = repository.queryByAmountRange(100.0, 250.0); + + assertThat(result).hasSize(3); // 100, 150, 250 + // Should be sorted DESC + assertThat(result.get(0).getAmount()).isEqualTo(250.0); + assertThat(result.get(1).getAmount()).isEqualTo(150.0); + assertThat(result.get(2).getAmount()).isEqualTo(100.0); + } + + @Test + @Order(4) + @DisplayName("@Query with count return type") + void countByMinAmount() { + long count = repository.countByMinAmount(200.0); + + assertThat(count).isEqualTo(2); // 250, 300 + } + + @Test + @Order(5) + @DisplayName("@Query with boolean return type (exists)") + void existsWithStatus() { + assertThat(repository.existsWithStatus("OPEN")).isTrue(); + assertThat(repository.existsWithStatus("CANCELLED")).isFalse(); + } + + @Test + @Order(6) + @DisplayName("@Query WHERE field IS NOT NULL") + void queryAllWithCustomerId() { + List result = repository.queryAllWithCustomerId(); + + assertThat(result).hasSize(5); // All have customerId set + // Should be sorted by customerId ASC + assertThat(result.get(0).getCustomerId()).isEqualTo("C1"); + assertThat(result.get(4).getCustomerId()).isEqualTo("C5"); + } + + @Test + @Order(7) + @DisplayName("@Query with OR combinator") + void queryByEitherStatus() { + List result = repository.queryByEitherStatus("OPEN", "PENDING"); + + assertThat(result).hasSize(3); // 2 OPEN + 1 PENDING + assertThat(result).allSatisfy(o -> + assertThat(o.getStatus()).isIn("OPEN", "PENDING")); + } + + @Test + @Order(8) + @DisplayName("@Query with implicit @Param via -parameters compiler option (single param)") + void queryByStatusImplicitParam() { + List open = repository.queryByStatusImplicitParam("OPEN"); + + assertThat(open).hasSize(2); + assertThat(open).allSatisfy(o -> assertThat(o.getStatus()).isEqualTo("OPEN")); + assertThat(open.get(0).getAmount()).isEqualTo(100.0); + assertThat(open.get(1).getAmount()).isEqualTo(250.0); + } + + @Test + @Order(9) + @DisplayName("@Query with implicit @Param via -parameters compiler option (multiple params)") + void queryByStatusAndMinAmountImplicit() { + List result = repository.queryByStatusAndMinAmountImplicit("OPEN", 150.0); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getAmount()).isEqualTo(250.0); + } + + private void createOrder(String customerId, double amount, String status) { + var order = new OrderEntity(); + order.setCustomerId(customerId); + order.setAmount(amount); + order.setStatus(status); + morphium.store(order); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataMetamodelTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataMetamodelTest.java new file mode 100644 index 000000000..f30bc5e31 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataMetamodelTest.java @@ -0,0 +1,222 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.data.Sort; +import jakarta.data.Order; +import jakarta.data.metamodel.Attribute; +import jakarta.data.metamodel.SortableAttribute; +import jakarta.data.metamodel.StaticMetamodel; +import jakarta.data.metamodel.TextAttribute; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for Jakarta Data Phase 6: @StaticMetamodel generation. + */ +@QuarkusTest +@DisplayName("Jakarta Data @StaticMetamodel Generation") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataMetamodelTest { + + @Inject + OrderRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.clearCollection(OrderEntity.class); + + createOrder("C1", 100.0, "OPEN"); + createOrder("C2", 250.0, "OPEN"); + createOrder("C3", 50.0, "CLOSED"); + createOrder("C4", 300.0, "CLOSED"); + createOrder("C5", 150.0, "PENDING"); + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + // -- Metamodel class existence -- + + @Test + @org.junit.jupiter.api.Order(1) + @DisplayName("OrderEntity_ metamodel class exists and is annotated") + void metamodelClassExists() throws Exception { + Class metamodel = Class.forName("de.caluga.morphium.quarkus.it.OrderEntity_"); + assertThat(metamodel).isNotNull(); + + StaticMetamodel annotation = metamodel.getAnnotation(StaticMetamodel.class); + assertThat(annotation).isNotNull(); + assertThat(annotation.value()).isEqualTo(OrderEntity.class); + } + + @Test + @org.junit.jupiter.api.Order(2) + @DisplayName("ItemEntity_ metamodel class exists and is annotated") + void itemMetamodelClassExists() throws Exception { + Class metamodel = Class.forName("de.caluga.morphium.quarkus.it.ItemEntity_"); + assertThat(metamodel).isNotNull(); + + StaticMetamodel annotation = metamodel.getAnnotation(StaticMetamodel.class); + assertThat(annotation).isNotNull(); + assertThat(annotation.value()).isEqualTo(ItemEntity.class); + } + + // -- String constants -- + + @Test + @org.junit.jupiter.api.Order(3) + @DisplayName("OrderEntity_ has String constants for all fields") + void stringConstants() throws Exception { + Class m = Class.forName("de.caluga.morphium.quarkus.it.OrderEntity_"); + + // Check String constants exist and have correct values + assertStringConstant(m, "ID", "id"); + assertStringConstant(m, "CUSTOMER_ID", "customerId"); + assertStringConstant(m, "AMOUNT", "amount"); + assertStringConstant(m, "STATUS", "status"); + assertStringConstant(m, "CREATED_AT", "createdAt"); + assertStringConstant(m, "VERSION", "version"); + } + + // -- Attribute types -- + + @Test + @org.junit.jupiter.api.Order(4) + @DisplayName("OrderEntity_ String fields are TextAttribute") + void textAttributes() throws Exception { + Class m = Class.forName("de.caluga.morphium.quarkus.it.OrderEntity_"); + + Object statusAttr = m.getField("status").get(null); + assertThat(statusAttr).isInstanceOf(TextAttribute.class); + assertThat(((Attribute) statusAttr).name()).isEqualTo("status"); + + Object customerIdAttr = m.getField("customerId").get(null); + assertThat(customerIdAttr).isInstanceOf(TextAttribute.class); + } + + @Test + @org.junit.jupiter.api.Order(5) + @DisplayName("OrderEntity_ numeric fields are SortableAttribute") + void sortableAttributes() throws Exception { + Class m = Class.forName("de.caluga.morphium.quarkus.it.OrderEntity_"); + + Object amountAttr = m.getField("amount").get(null); + assertThat(amountAttr).isInstanceOf(SortableAttribute.class); + assertThat(((Attribute) amountAttr).name()).isEqualTo("amount"); + } + + @Test + @org.junit.jupiter.api.Order(6) + @DisplayName("OrderEntity_ date fields are SortableAttribute") + void dateAttributes() throws Exception { + Class m = Class.forName("de.caluga.morphium.quarkus.it.OrderEntity_"); + + Object createdAtAttr = m.getField("createdAt").get(null); + assertThat(createdAtAttr).isInstanceOf(SortableAttribute.class); + assertThat(((Attribute) createdAtAttr).name()).isEqualTo("createdAt"); + } + + // -- Attribute functional usage -- + + @Test + @org.junit.jupiter.api.Order(7) + @DisplayName("TextAttribute.asc() creates correct Sort") + void textAttributeAsc() throws Exception { + Class m = Class.forName("de.caluga.morphium.quarkus.it.OrderEntity_"); + @SuppressWarnings("unchecked") + TextAttribute statusAttr = + (TextAttribute) m.getField("status").get(null); + + Sort sort = statusAttr.asc(); + assertThat(sort.property()).isEqualTo("status"); + assertThat(sort.isAscending()).isTrue(); + assertThat(sort.ignoreCase()).isFalse(); + } + + @Test + @org.junit.jupiter.api.Order(8) + @DisplayName("SortableAttribute.desc() creates correct Sort") + void sortableAttributeDesc() throws Exception { + Class m = Class.forName("de.caluga.morphium.quarkus.it.OrderEntity_"); + @SuppressWarnings("unchecked") + SortableAttribute amountAttr = + (SortableAttribute) m.getField("amount").get(null); + + Sort sort = amountAttr.desc(); + assertThat(sort.property()).isEqualTo("amount"); + assertThat(sort.isDescending()).isTrue(); + } + + @Test + @org.junit.jupiter.api.Order(9) + @DisplayName("TextAttribute.ascIgnoreCase() creates correct Sort") + void textAttributeAscIgnoreCase() throws Exception { + Class m = Class.forName("de.caluga.morphium.quarkus.it.OrderEntity_"); + @SuppressWarnings("unchecked") + TextAttribute statusAttr = + (TextAttribute) m.getField("status").get(null); + + Sort sort = statusAttr.ascIgnoreCase(); + assertThat(sort.property()).isEqualTo("status"); + assertThat(sort.isAscending()).isTrue(); + assertThat(sort.ignoreCase()).isTrue(); + } + + // -- Practical usage with repository -- + + @Test + @org.junit.jupiter.api.Order(10) + @DisplayName("Metamodel attributes can be used to build Order for findAll") + void metamodelWithRepository() throws Exception { + Class m = Class.forName("de.caluga.morphium.quarkus.it.OrderEntity_"); + @SuppressWarnings("unchecked") + SortableAttribute amountAttr = + (SortableAttribute) m.getField("amount").get(null); + + // Use metamodel attribute to create Sort, then wrap in Order + Sort sortByAmount = amountAttr.desc(); + Order order = Order.by(sortByAmount); + + var page = repository.findAll( + jakarta.data.page.PageRequest.ofSize(3), + order); + + assertThat(page.content()).hasSize(3); + // Should be sorted by amount DESC: 300, 250, 150 + assertThat(page.content().get(0).getAmount()).isEqualTo(300.0); + assertThat(page.content().get(1).getAmount()).isEqualTo(250.0); + assertThat(page.content().get(2).getAmount()).isEqualTo(150.0); + } + + // -- Helpers -- + + private void assertStringConstant(Class metamodel, String constantName, String expectedValue) + throws Exception { + Field field = metamodel.getField(constantName); + assertThat(field).isNotNull(); + assertThat(Modifier.isStatic(field.getModifiers())).isTrue(); + assertThat(Modifier.isFinal(field.getModifiers())).isTrue(); + assertThat(field.getType()).isEqualTo(String.class); + assertThat(field.get(null)).isEqualTo(expectedValue); + } + + private void createOrder(String customerId, double amount, String status) { + var order = new OrderEntity(); + order.setCustomerId(customerId); + order.setAmount(amount); + order.setStatus(status); + morphium.store(order); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataMorphiumRepositoryTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataMorphiumRepositoryTest.java new file mode 100644 index 000000000..2a2d27ce9 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataMorphiumRepositoryTest.java @@ -0,0 +1,116 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.query.Query; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@link MorphiumItemRepository} — verifies that + * MorphiumRepository's distinct(), morphium() and query() methods work. + */ +@QuarkusTest +@DisplayName("MorphiumRepository operations") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataMorphiumRepositoryTest { + + @Inject + MorphiumItemRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void cleanCollection() { + morphium.clearCollection(ItemEntity.class); + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + @Test + @Order(1) + @DisplayName("MorphiumRepository is injectable") + void repository_isInjectable() { + assertThat(repository).isNotNull(); + } + + @Test + @Order(2) + @DisplayName("CRUD operations work through MorphiumRepository") + void crud_worksThroughMorphiumRepository() { + var item = new ItemEntity(); + item.setName("Widget"); + item.setPrice(9.99); + item.setTag("tools"); + repository.save(item); + + assertThat(item.getId()).isNotNull(); + assertThat(repository.findById(item.getId())).isPresent(); + } + + @Test + @Order(3) + @DisplayName("distinct() returns unique field values") + void distinct_returnsUniqueValues() { + createItem("A", "electronics"); + createItem("B", "electronics"); + createItem("C", "tools"); + createItem("D", "books"); + + List distinctTags = repository.distinct("tag"); + assertThat(distinctTags).containsExactlyInAnyOrder("electronics", "tools", "books"); + } + + @Test + @Order(4) + @DisplayName("morphium() returns the Morphium instance") + void morphium_returnsMorphiumInstance() { + Morphium m = repository.morphium(); + assertThat(m).isNotNull(); + assertThat(m).isSameAs(morphium); + } + + @Test + @Order(5) + @DisplayName("query() creates a typed Query for the entity") + void query_createsTypedQuery() { + createItem("Alpha", "tools"); + createItem("Beta", "electronics"); + + Query q = repository.query(); + assertThat(q).isNotNull(); + + q.f("tag").eq("tools"); + List results = q.asList(); + assertThat(results).hasSize(1); + assertThat(results.get(0).getName()).isEqualTo("Alpha"); + } + + @Test + @Order(6) + @DisplayName("query derivation works through MorphiumRepository") + void queryDerivation_worksThroughMorphiumRepository() { + createItem("X", "widgets"); + createItem("Y", "widgets"); + createItem("Z", "gadgets"); + + List widgets = repository.findByTag("widgets"); + assertThat(widgets).hasSize(2); + } + + private void createItem(String name, String tag) { + var item = new ItemEntity(); + item.setName(name); + item.setPrice(10.0); + item.setTag(tag); + repository.save(item); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataOperatorTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataOperatorTest.java new file mode 100644 index 000000000..3de998f30 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataOperatorTest.java @@ -0,0 +1,163 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for Jakarta Data #2: missing query derivation operators. + * Tests Contains, NotContains, IsEmpty, IsNotEmpty, Size, Matches, IgnoreCase. + */ +@QuarkusTest +@DisplayName("Jakarta Data Query Derivation — New Operators") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataOperatorTest { + + @Inject + OrderRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.clearCollection(OrderEntity.class); + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + @Test + @Order(1) + @DisplayName("findByTagsContains finds orders containing the given tag") + void findByTagsContains_findsMatching() { + var o1 = order("C1", 100, "OPEN", List.of("VIP", "RUSH")); + var o2 = order("C2", 200, "OPEN", List.of("STANDARD")); + morphium.store(o1); + morphium.store(o2); + + List result = repository.findByTagsContains("VIP"); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getCustomerId()).isEqualTo("C1"); + } + + @Test + @Order(2) + @DisplayName("findByTagsNotContains excludes orders containing the given tag") + void findByTagsNotContains_excludesMatching() { + var o1 = order("C1", 100, "OPEN", List.of("VIP", "RUSH")); + var o2 = order("C2", 200, "OPEN", List.of("STANDARD")); + var o3 = order("C3", 50, "CLOSED", null); + morphium.store(o1); + morphium.store(o2); + morphium.store(o3); + + List result = repository.findByTagsNotContains("VIP"); + + assertThat(result).hasSize(2); + assertThat(result).extracting(OrderEntity::getCustomerId) + .containsExactlyInAnyOrder("C2", "C3"); + } + + @Test + @Order(3) + @DisplayName("findByTagsIsEmpty finds orders with empty tags array") + void findByTagsIsEmpty_findsEmpty() { + var o1 = order("C1", 100, "OPEN", List.of("VIP")); + var o2 = order("C2", 200, "OPEN", List.of()); + morphium.store(o1); + morphium.store(o2); + + List result = repository.findByTagsIsEmpty(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getCustomerId()).isEqualTo("C2"); + } + + @Test + @Order(4) + @DisplayName("findByTagsIsNotEmpty finds orders with non-empty tags") + void findByTagsIsNotEmpty_findsNonEmpty() { + var o1 = order("C1", 100, "OPEN", List.of("VIP")); + var o2 = order("C2", 200, "OPEN", List.of()); + morphium.store(o1); + morphium.store(o2); + + List result = repository.findByTagsIsNotEmpty(); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getCustomerId()).isEqualTo("C1"); + } + + @Test + @Order(5) + @DisplayName("findByTagsSize matches exact array size") + void findByTagsSize_matchesExact() { + var o1 = order("C1", 100, "OPEN", List.of()); + var o2 = order("C2", 200, "OPEN", List.of("VIP", "RUSH")); + var o3 = order("C3", 50, "CLOSED", List.of("A", "B", "C")); + morphium.store(o1); + morphium.store(o2); + morphium.store(o3); + + List result = repository.findByTagsSize(2); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getCustomerId()).isEqualTo("C2"); + } + + @Test + @Order(6) + @DisplayName("findByCustomerIdMatches filters by regex pattern") + void findByCustomerIdMatches_regex() { + var o1 = order("CUST-001", 100, "OPEN", List.of()); + var o2 = order("CUST-002", 200, "OPEN", List.of()); + var o3 = order("OTHER", 50, "CLOSED", List.of()); + morphium.store(o1); + morphium.store(o2); + morphium.store(o3); + + List result = repository.findByCustomerIdMatches("CUST-.*"); + + assertThat(result).hasSize(2); + assertThat(result).extracting(OrderEntity::getCustomerId) + .containsExactlyInAnyOrder("CUST-001", "CUST-002"); + } + + @Test + @Order(7) + @DisplayName("findByStatusIgnoreCase matches case-insensitively") + void findByStatusIgnoreCase_caseInsensitive() { + var o1 = order("C1", 100, "OPEN", List.of()); + var o2 = order("C2", 200, "Open", List.of()); + var o3 = order("C3", 50, "open", List.of()); + var o4 = order("C4", 75, "CLOSED", List.of()); + morphium.store(o1); + morphium.store(o2); + morphium.store(o3); + morphium.store(o4); + + List result = repository.findByStatusIgnoreCase("open"); + + assertThat(result).hasSize(3); + assertThat(result).extracting(OrderEntity::getCustomerId) + .containsExactlyInAnyOrder("C1", "C2", "C3"); + } + + private OrderEntity order(String customerId, double amount, String status, List tags) { + var o = new OrderEntity(); + o.setCustomerId(customerId); + o.setAmount(amount); + o.setStatus(status); + o.setTags(tags); + return o; + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataPaginationTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataPaginationTest.java new file mode 100644 index 000000000..db1644f0b --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataPaginationTest.java @@ -0,0 +1,83 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for Jakarta Data pagination and sorting. + */ +@QuarkusTest +@DisplayName("Jakarta Data Pagination & Sorting") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataPaginationTest { + + @Inject + OrderRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.clearCollection(OrderEntity.class); + for (int i = 1; i <= 25; i++) { + var order = new OrderEntity(); + order.setCustomerId("C" + i); + order.setAmount(i * 10.0); + order.setStatus(i % 2 == 0 ? "OPEN" : "CLOSED"); + morphium.store(order); + } + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + @Test + @Order(1) + @DisplayName("findByStatus returns correct filtered results") + void findByStatus_paginationPrep() { + var openOrders = repository.findByStatus("OPEN"); + assertThat(openOrders).hasSize(12); // even numbers 2,4,...,24 + + var closedOrders = repository.findByStatus("CLOSED"); + assertThat(closedOrders).hasSize(13); // odd numbers 1,3,...,25 + } + + @Test + @Order(2) + @DisplayName("findAll returns all entities as Stream") + void findAll_total() { + long total = repository.findAll().count(); + assertThat(total).isEqualTo(25); + } + + @Test + @Order(3) + @DisplayName("countByStatus returns correct filtered count") + void countByStatus() { + assertThat(repository.countByStatus("OPEN")).isEqualTo(12); + assertThat(repository.countByStatus("CLOSED")).isEqualTo(13); + } + + @Test + @Order(4) + @DisplayName("findByAmountGreaterThan with boundary value") + void findByAmountGreaterThan_boundary() { + var result = repository.findByAmountGreaterThan(200.0); + assertThat(result).hasSize(5); // 210, 220, 230, 240, 250 + } + + @Test + @Order(5) + @DisplayName("existsByStatus works for query derivation") + void existsByStatus() { + assertThat(repository.existsByStatus("OPEN")).isTrue(); + assertThat(repository.existsByStatus("INVALID")).isFalse(); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataParenGroupTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataParenGroupTest.java new file mode 100644 index 000000000..d4b19da40 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataParenGroupTest.java @@ -0,0 +1,124 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for JDQL parenthesized group conditions. + * Verifies that queries like {@code WHERE a = :a AND (b IS NULL OR b = '')} + * correctly scope the OR to the parenthesized group and don't leak data + * across unrelated filter values. + */ +@QuarkusTest +@DisplayName("JDQL Parenthesized Group Queries") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataParenGroupTest { + + @Inject + OrderRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.clearCollection(OrderEntity.class); + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + @Test + @Order(1) + @DisplayName("AND with OR group: only matching status + (NULL or empty) customerId") + void queryByStatusWithNullOrEmptyCustomerId() { + // OPEN with null customerId — should match + createOrder(null, 100.0, "OPEN", false); + // OPEN with empty customerId — should match + createOrder("", 200.0, "OPEN", false); + // OPEN with non-empty customerId — should NOT match + createOrder("C1", 300.0, "OPEN", false); + // CLOSED with null customerId — should NOT match (wrong status) + createOrder(null, 400.0, "CLOSED", false); + // CLOSED with empty customerId — should NOT match (wrong status) + createOrder("", 500.0, "CLOSED", false); + + List result = repository.queryByStatusWithNullOrEmptyCustomerId("OPEN"); + + // Only the 2 OPEN orders with null/empty customerId + assertThat(result).hasSize(2); + assertThat(result).allSatisfy(o -> { + assertThat(o.getStatus()).isEqualTo("OPEN"); + assertThat(o.getCustomerId() == null || o.getCustomerId().isEmpty()).isTrue(); + }); + } + + @Test + @Order(2) + @DisplayName("No cross-status leakage: CLOSED orders not returned when querying OPEN") + void noCrossStatusLeakage() { + // This is the exact bug scenario from OTA Authority: + // Without parenthesis-aware parsing, the OR would be top-level, + // returning ALL orders where customerId IS NULL regardless of status. + createOrder(null, 100.0, "OPEN", false); + createOrder(null, 200.0, "CLOSED", false); + createOrder(null, 300.0, "PENDING", false); + + List result = repository.queryByStatusWithNullOrEmptyCustomerId("OPEN"); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getStatus()).isEqualTo("OPEN"); + } + + @Test + @Order(3) + @DisplayName("AND with OR group using params and boolean: status + (amount > min OR urgent)") + void queryByStatusWithAmountOrUrgent() { + // OPEN, amount 50, not urgent — should NOT match (50 <= 100 and not urgent) + createOrder("C1", 50.0, "OPEN", false); + // OPEN, amount 200, not urgent — should match (200 > 100) + createOrder("C2", 200.0, "OPEN", false); + // OPEN, amount 30, urgent — should match (urgent = true) + createOrder("C3", 30.0, "OPEN", true); + // CLOSED, amount 200, urgent — should NOT match (wrong status) + createOrder("C4", 200.0, "CLOSED", true); + + List result = repository.queryByStatusWithAmountOrUrgent("OPEN", 100.0); + + assertThat(result).hasSize(2); + assertThat(result).allSatisfy(o -> assertThat(o.getStatus()).isEqualTo("OPEN")); + // Sorted by amount ASC: 30 (urgent), then 200 + assertThat(result.get(0).getAmount()).isEqualTo(30.0); + assertThat(result.get(1).getAmount()).isEqualTo(200.0); + } + + @Test + @Order(4) + @DisplayName("Empty result when no matches for parenthesized group") + void emptyResultWhenNoMatch() { + createOrder("C1", 100.0, "OPEN", false); + createOrder("C2", 200.0, "OPEN", false); + + // All OPEN orders have non-empty customerId → no match + List result = repository.queryByStatusWithNullOrEmptyCustomerId("OPEN"); + + assertThat(result).isEmpty(); + } + + private void createOrder(String customerId, double amount, String status, boolean urgent) { + var order = new OrderEntity(); + order.setCustomerId(customerId); + order.setAmount(amount); + order.setStatus(status); + order.setUrgent(urgent); + morphium.store(order); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataProjectionTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataProjectionTest.java new file mode 100644 index 000000000..80a2c032e --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataProjectionTest.java @@ -0,0 +1,141 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for JDQL SELECT with Projection (#7). + */ +@QuarkusTest +@DisplayName("Jakarta Data JDQL SELECT Projection") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataProjectionTest { + + @Inject + OrderRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.clearCollection(OrderEntity.class); + for (int i = 1; i <= 10; i++) { + var order = new OrderEntity(); + order.setCustomerId("C" + i); + order.setAmount(i * 100.0); + order.setStatus(i <= 5 ? "OPEN" : "CLOSED"); + morphium.store(order); + } + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + @Test + @Order(1) + @DisplayName("#1 SELECT fields — only projected fields populated, others null/default") + void selectFields_onlyProjectedFieldsPopulated() { + List results = repository.queryProjectedByStatus("OPEN"); + + assertThat(results).hasSize(5); + for (OrderEntity e : results) { + // Projected fields are populated + assertThat(e.getCustomerId()).isNotNull(); + assertThat(e.getAmount()).isGreaterThan(0); + // _id is always included by MongoDB + assertThat(e.getId()).isNotNull(); + // Non-projected fields are null/default + assertThat(e.getStatus()).isNull(); + assertThat(e.getCreatedAt()).isNull(); + assertThat(e.getTags()).isNull(); + } + // Verify ordering + assertThat(results).extracting(OrderEntity::getAmount).isSorted(); + } + + @Test + @Order(2) + @DisplayName("#2 SELECT with FROM clause — FROM is ignored, same results") + void selectWithFrom_ignored() { + List results = repository.queryProjectedWithFrom("OPEN"); + + assertThat(results).hasSize(5); + for (OrderEntity e : results) { + assertThat(e.getCustomerId()).isNotNull(); + assertThat(e.getAmount()).isGreaterThan(0); + assertThat(e.getStatus()).isNull(); + } + } + + @Test + @Order(3) + @DisplayName("#3 SELECT with Stream return type") + void selectWithStream_works() { + try (Stream stream = repository.queryProjectedStream(500.0)) { + List results = stream.toList(); + // amounts > 500: 600, 700, 800, 900, 1000 = 5 items + assertThat(results).hasSize(5); + for (OrderEntity e : results) { + assertThat(e.getCustomerId()).isNotNull(); + // amount is not projected — should be 0.0 (primitive default) + assertThat(e.getAmount()).isEqualTo(0.0); + assertThat(e.getStatus()).isNull(); + } + } + } + + @Test + @Order(4) + @DisplayName("#4 SELECT with single Optional result") + void selectSingle_projection() { + Optional result = repository.queryProjectedSingle("C1"); + + assertThat(result).isPresent(); + OrderEntity e = result.get(); + assertThat(e.getCustomerId()).isEqualTo("C1"); + assertThat(e.getAmount()).isEqualTo(100.0); + assertThat(e.getStatus()).isNull(); + assertThat(e.getCreatedAt()).isNull(); + } + + @Test + @Order(5) + @DisplayName("#5 No SELECT — all fields populated (regression check)") + void noSelect_allFieldsPopulated() { + List results = repository.queryByStatus("OPEN"); + + assertThat(results).hasSize(5); + for (OrderEntity e : results) { + assertThat(e.getCustomerId()).isNotNull(); + assertThat(e.getAmount()).isGreaterThan(0); + assertThat(e.getStatus()).isEqualTo("OPEN"); + assertThat(e.getCreatedAt()).isNotNull(); + } + } + + @Test + @Order(6) + @DisplayName("#6 SELECT with ORDER BY — correctly sorted and projected") + void selectWithOrderBy_works() { + List results = repository.queryProjectedByStatus("CLOSED"); + + assertThat(results).hasSize(5); + assertThat(results).extracting(OrderEntity::getAmount).isSorted(); + for (OrderEntity e : results) { + assertThat(e.getCustomerId()).isNotNull(); + assertThat(e.getAmount()).isGreaterThan(0); + assertThat(e.getStatus()).isNull(); + } + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataQueryTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataQueryTest.java new file mode 100644 index 000000000..386558ad8 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataQueryTest.java @@ -0,0 +1,251 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.data.Limit; +import jakarta.data.Sort; +import jakarta.data.page.Page; +import jakarta.data.page.PageRequest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for Jakarta Data query derivation via {@link OrderRepository}. + */ +@QuarkusTest +@DisplayName("Jakarta Data Query Derivation") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataQueryTest { + + @Inject + OrderRepository repository; + + @Inject + ItemRepository itemRepository; + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.clearCollection(OrderEntity.class); + + var o1 = new OrderEntity(); + o1.setCustomerId("C1"); + o1.setAmount(100.0); + o1.setStatus("OPEN"); + + var o2 = new OrderEntity(); + o2.setCustomerId("C2"); + o2.setAmount(250.0); + o2.setStatus("OPEN"); + + var o3 = new OrderEntity(); + o3.setCustomerId("C3"); + o3.setAmount(50.0); + o3.setStatus("CLOSED"); + + morphium.store(o1); + morphium.store(o2); + morphium.store(o3); + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + @Test + @Order(1) + @DisplayName("findByStatus returns matching entities") + void findByStatus() { + List open = repository.findByStatus("OPEN"); + + assertThat(open).hasSize(2); + assertThat(open).allSatisfy(o -> assertThat(o.getStatus()).isEqualTo("OPEN")); + } + + @Test + @Order(2) + @DisplayName("findByAmountGreaterThan filters correctly") + void findByAmountGreaterThan() { + List result = repository.findByAmountGreaterThan(100.0); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getAmount()).isEqualTo(250.0); + } + + @Test + @Order(3) + @DisplayName("findByAmountGreaterThanEqual includes boundary") + void findByAmountGreaterThanEqual() { + List result = repository.findByAmountGreaterThanEqual(100.0); + + assertThat(result).hasSize(2); + } + + @Test + @Order(4) + @DisplayName("findByAmountLessThan filters correctly") + void findByAmountLessThan() { + List result = repository.findByAmountLessThan(100.0); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getAmount()).isEqualTo(50.0); + } + + @Test + @Order(5) + @DisplayName("findByStatusAndAmountGreaterThan combines conditions") + void findByStatusAndAmount() { + List result = repository.findByStatusAndAmountGreaterThan("OPEN", 150.0); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getAmount()).isEqualTo(250.0); + } + + @Test + @Order(6) + @DisplayName("countByStatus returns correct count") + void countByStatus() { + long count = repository.countByStatus("OPEN"); + + assertThat(count).isEqualTo(2); + } + + @Test + @Order(7) + @DisplayName("existsByStatus returns true for existing") + void existsByStatus_true() { + assertThat(repository.existsByStatus("OPEN")).isTrue(); + } + + @Test + @Order(8) + @DisplayName("existsByStatus returns false for non-existing") + void existsByStatus_false() { + assertThat(repository.existsByStatus("CANCELLED")).isFalse(); + } + + @Test + @Order(9) + @DisplayName("findByName on ItemRepository with custom queries") + void findByName_onItemRepository() { + morphium.clearCollection(ItemEntity.class); + + var item = new ItemEntity(); + item.setName("TestItem"); + item.setPrice(42.0); + itemRepository.save(item); + + List found = itemRepository.findByName("TestItem"); + + assertThat(found).hasSize(1); + assertThat(found.get(0).getPrice()).isEqualTo(42.0); + } + + @Test + @Order(10) + @DisplayName("findByPriceGreaterThan on ItemRepository") + void findByPriceGreaterThan() { + morphium.clearCollection(ItemEntity.class); + + var cheap = new ItemEntity(); + cheap.setName("Cheap"); + cheap.setPrice(5.0); + + var expensive = new ItemEntity(); + expensive.setName("Expensive"); + expensive.setPrice(100.0); + + itemRepository.save(cheap); + itemRepository.save(expensive); + + List result = itemRepository.findByPriceGreaterThan(50.0); + + assertThat(result).hasSize(1); + assertThat(result.get(0).getName()).isEqualTo("Expensive"); + } + + // -- Regression: dynamic Sort/Limit/PageRequest parameters on a derived findBy* method -- + + @Test + @Order(11) + @DisplayName("findByStatus(Sort): dynamic Sort parameter is applied, not silently ignored") + void findByStatusSorted() { + List ascending = repository.findByStatus("OPEN", Sort.asc("amount")); + assertThat(ascending).extracting(OrderEntity::getAmount).containsExactly(100.0, 250.0); + + List descending = repository.findByStatus("OPEN", Sort.desc("amount")); + assertThat(descending).extracting(OrderEntity::getAmount).containsExactly(250.0, 100.0); + } + + @Test + @Order(12) + @DisplayName("findByStatus(Limit): dynamic Limit parameter is applied, not silently ignored") + void findByStatusLimited() { + List limited = repository.findByStatus("OPEN", Limit.of(1)); + assertThat(limited).hasSize(1); + } + + @Test + @Order(13) + @DisplayName("findByStatus(PageRequest): dynamic PageRequest parameter returns a Page, not a ClassCastException") + void findByStatusPaged() { + Page page = repository.findByStatus("OPEN", PageRequest.ofSize(1)); + assertThat(page.content()).hasSize(1); + assertThat(page.totalElements()).isEqualTo(2); + } + + // -- Regression: dynamic Sort parameter on deleteBy*/countBy*/existsBy* -- + // + // Before the fix, QueryMethodBridge#executeQuery(..., sortParamIndex, ...) always fell + // through to the FIND branch once any dynamic parameter was present, regardless of the + // method's actual prefix. For deleteByStatus(Sort) that meant the query was built and + // sorted/asList()'d but never deleted -- the collection was left untouched while a + // "successful" long was still returned. For countByStatus(Sort)/existsByStatus(Sort) the + // FIND branch returned a List where the generated bytecode expected a Long/boolean, + // throwing a ClassCastException. These tests assert the real, observable effect (actual + // document count in the database, not just the return value) so a regression back to the + // old behaviour would be caught. + + @Test + @Order(14) + @DisplayName("deleteByStatus(Sort): dynamic Sort parameter is accepted and the matching documents are actually deleted") + void deleteByStatusSorted() { + long before = morphium.createQueryFor(OrderEntity.class).countAll(); + assertThat(before).isEqualTo(3); + + long deleted = repository.deleteByStatus("OPEN", Sort.asc("amount")); + + assertThat(deleted).isEqualTo(2); + + // The real effect: the OPEN documents must actually be gone from the database, not just + // a plausible-looking return value while the collection stayed untouched. + long after = morphium.createQueryFor(OrderEntity.class).countAll(); + assertThat(after).isEqualTo(1); + assertThat(repository.findByStatus("OPEN")).isEmpty(); + assertThat(repository.findByStatus("CLOSED")).hasSize(1); + } + + @Test + @Order(15) + @DisplayName("countByStatus(Sort): dynamic Sort parameter is accepted and the correct count (a long, not a List) is returned") + void countByStatusSorted() { + long count = repository.countByStatus("OPEN", Sort.asc("amount")); + + assertThat(count).isEqualTo(2); + } + + @Test + @Order(16) + @DisplayName("existsByStatus(Sort): dynamic Sort parameter is accepted and the correct boolean (not a List) is returned") + void existsByStatusSorted() { + assertThat(repository.existsByStatus("OPEN", Sort.asc("amount"))).isTrue(); + assertThat(repository.existsByStatus("CANCELLED", Sort.asc("amount"))).isFalse(); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataStreamTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataStreamTest.java new file mode 100644 index 000000000..38aa1a3af --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDataStreamTest.java @@ -0,0 +1,111 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.util.List; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for lazy Stream support in Jakarta Data repositories. + */ +@QuarkusTest +@DisplayName("Jakarta Data Lazy Stream Support") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumDataStreamTest { + + @Inject + OrderRepository repository; + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.clearCollection(OrderEntity.class); + for (int i = 1; i <= 50; i++) { + var order = new OrderEntity(); + order.setCustomerId("C" + i); + order.setAmount(i * 10.0); + order.setStatus(i <= 30 ? "OPEN" : "CLOSED"); + morphium.store(order); + } + } + + @AfterEach + void resetThreadLocals() { + morphium.resetThreadLocalOverrides(); + } + + @Test + @Order(1) + @DisplayName("#1 findAll() stream with limit returns partial results") + void findAll_streamWithLimit() { + try (Stream stream = repository.findAll()) { + List limited = stream.limit(5).toList(); + assertThat(limited).hasSize(5); + } + } + + @Test + @Order(2) + @DisplayName("#2 findAll() stream close is idempotent") + void findAll_streamCloseIsIdempotent() { + Stream stream = repository.findAll(); + stream.close(); + stream.close(); // second close should not throw + } + + @Test + @Order(3) + @DisplayName("#3 Query derivation stream returns correct sorted results") + void queryDerivation_streamReturn() { + try (Stream stream = repository.findByAmountGreaterThanEqualOrderByAmountAsc(400)) { + List result = stream.toList(); + assertThat(result).hasSize(11); // amounts 400,410,...,500 + assertThat(result).extracting(OrderEntity::getAmount) + .isSorted(); + } + } + + @Test + @Order(4) + @DisplayName("#4 @Find annotated method with Stream return works") + void findAnnotation_streamReturn() { + try (Stream stream = repository.findStreamByStatus("OPEN")) { + List result = stream.toList(); + assertThat(result).hasSize(30); + // Verify ordering by amount (from @OrderBy) + assertThat(result).extracting(OrderEntity::getAmount) + .isSorted(); + } + } + + @Test + @Order(5) + @DisplayName("#5 @Query annotated method with Stream return works") + void queryAnnotation_streamReturn() { + try (Stream stream = repository.queryStreamByStatus("CLOSED")) { + List result = stream.toList(); + assertThat(result).hasSize(20); + assertThat(result).extracting(OrderEntity::getAmount) + .isSorted(); + } + } + + @Test + @Order(6) + @DisplayName("#6 Stream with try-with-resources and intermediate operations") + void stream_withTryWithResources() { + try (Stream stream = repository.findAll()) { + long count = stream + .filter(o -> o.getAmount() > 200) + .count(); + assertThat(count).isEqualTo(30); // amounts 210..500 + } + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDevServicesConfigTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDevServicesConfigTest.java new file mode 100644 index 000000000..bcdd6ad31 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDevServicesConfigTest.java @@ -0,0 +1,73 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import io.quarkus.test.junit.QuarkusTest; +import org.eclipse.microprofile.config.ConfigProvider; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that the Dev Services build-time config defaults are correct + * and that the InMemDriver test profile suppresses Dev Services as expected. + * + *

    In this test profile {@code quarkus.morphium.devservices.enabled=false} + * and {@code quarkus.morphium.driver-name=InMemDriver} are set via application.properties, + * so no container is started. + */ +@QuarkusTest +@DisplayName("Dev Services configuration") +class MorphiumDevServicesConfigTest { + + @Test + @DisplayName("Dev Services disabled in test profile – no container host override") + void devServicesDisabled_hostsNotOverridden() { + // With devservices.enabled=false and InMemDriver, quarkus.morphium.hosts is never + // injected by the DevServicesProcessor. The config value stays absent + // (or at the @WithDefault "localhost:27017"). + var hosts = ConfigProvider.getConfig() + .getOptionalValue("quarkus.morphium.hosts", String.class); + + // Either absent (user never set it) or the default – never a random container port. + // Container ports are typically >= 30000; the MongoDB default port is 27017. + hosts.ifPresent(h -> + assertThat(h) + .as("hosts must not be a dev-services container port in test profile") + .satisfiesAnyOf( + v -> assertThat(v).isEqualTo("localhost:27017"), + v -> assertThat(Integer.parseInt(v.split(":")[1])).isLessThan(30000) + ) + ); + } + + @Test + @DisplayName("quarkus.morphium.database config property is readable") + void databaseConfigIsReadable() { + String db = ConfigProvider.getConfig() + .getValue("quarkus.morphium.database", String.class); + assertThat(db).isEqualTo("it-db"); + } + + @Test + @DisplayName("quarkus.morphium.driver-name config property is readable") + void driverNameConfigIsReadable() { + String driver = ConfigProvider.getConfig() + .getValue("quarkus.morphium.driver-name", String.class); + assertThat(driver).isEqualTo("InMemDriver"); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDevServicesReplicaSetConfigTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDevServicesReplicaSetConfigTest.java new file mode 100644 index 000000000..548a9540d --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumDevServicesReplicaSetConfigTest.java @@ -0,0 +1,89 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import org.eclipse.microprofile.config.ConfigProvider; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration test verifying that the application starts successfully when + * {@code quarkus.morphium.devservices.replica-set=true} is set alongside + * {@code devservices.enabled=false}. + * + *

    No MongoDB container is started. This test only proves that the config + * overrides are present in MicroProfile Config and that startup completes + * without errors — it does not prove that Quarkus treats the key as + * a recognised {@code @ConfigMapping} property (MicroProfile Config returns + * arbitrary keys from any config source). The actual {@code @ConfigMapping} + * binding (property-name → {@code replicaSet()} method) is covered by + * {@code MorphiumDevServicesConfigDefaultsTest} in the deployment module. + */ +@QuarkusTest +@TestProfile(MorphiumDevServicesReplicaSetConfigTest.ReplicaSetEnabledProfile.class) +@DisplayName("Dev Services – startup with replica-set override (no container)") +class MorphiumDevServicesReplicaSetConfigTest { + + /** + * Test profile that enables the {@code replica-set} flag while keeping + * Dev Services disabled so no Docker container is started. + */ + public static class ReplicaSetEnabledProfile implements QuarkusTestProfile { + + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.morphium.driver-name", "InMemDriver", + "quarkus.morphium.database", "replset-cfg-test", + "quarkus.morphium.devservices.enabled", "false", + "quarkus.morphium.devservices.replica-set", "true" + ); + } + } + + @Test + @DisplayName("replica-set=true with devservices.enabled=false does not prevent startup") + void replicaSet_withDevServicesDisabled_appStartsSuccessfully() { + // Reaching this point means the Quarkus application started without errors + // despite replica-set=true being set. We assert both overrides are present + // in MicroProfile Config. Note: this does NOT prove Quarkus treats them as + // recognised @ConfigMapping properties — MicroProfile Config returns any key + // from any config source. The actual binding is tested by + // MorphiumDevServicesConfigDefaultsTest in the deployment module. + assertThat(ConfigProvider.getConfig() + .getValue("quarkus.morphium.devservices.enabled", String.class)) + .isEqualTo("false"); + assertThat(ConfigProvider.getConfig() + .getValue("quarkus.morphium.devservices.replica-set", String.class)) + .as("replica-set profile override must be present") + .isEqualTo("true"); + } + + @Test + @DisplayName("driver is InMemDriver (no MongoDB connection needed)") + void driver_isInMemory() { + assertThat(ConfigProvider.getConfig() + .getValue("quarkus.morphium.driver-name", String.class)) + .isEqualTo("InMemDriver"); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumEmbeddedTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumEmbeddedTest.java new file mode 100644 index 000000000..6312dc5e7 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumEmbeddedTest.java @@ -0,0 +1,134 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for Morphium's {@code @Embedded} document support. + * Verifies that nested sub-documents are stored and retrieved correctly. + */ +@QuarkusTest +@DisplayName("@Embedded document support") +class MorphiumEmbeddedTest { + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.dropCollection(CustomerEntity.class); + morphium.ensureIndicesFor(CustomerEntity.class); + } + + @Test + @DisplayName("store/retrieve entity with fully populated embedded address") + void roundtrip_withEmbeddedAddress() { + var customer = customerWith("Alice", "Elm St 42", "Springfield", "12345"); + morphium.store(customer); + assertThat(customer.getId()).isNotNull(); + + var found = byName("Alice"); + assertThat(found).isNotNull(); + assertThat(found.getAddress()).isNotNull() + .satisfies(a -> { + assertThat(a.getStreet()).isEqualTo("Elm St 42"); + assertThat(a.getCity()).isEqualTo("Springfield"); + assertThat(a.getZip()).isEqualTo("12345"); + }); + } + + @Test + @DisplayName("store/retrieve entity with null embedded address") + void roundtrip_withNullAddress() { + var customer = new CustomerEntity(); + customer.setName("Bob"); + customer.setAddress(null); + morphium.store(customer); + + var found = byName("Bob"); + assertThat(found).isNotNull(); + assertThat(found.getAddress()).isNull(); + } + + @Test + @DisplayName("embedded address can be replaced on update") + void update_replacesEmbeddedAddress() { + var customer = customerWith("Carol", "Old Lane 1", "Old Town", "00000"); + morphium.store(customer); + + customer.setAddress(address("New Ave 7", "New Town", "99999")); + morphium.store(customer); + + var found = byName("Carol"); + assertThat(found.getAddress()) + .satisfies(a -> { + assertThat(a.getStreet()).isEqualTo("New Ave 7"); + assertThat(a.getCity()).isEqualTo("New Town"); + assertThat(a.getZip()).isEqualTo("99999"); + }); + } + + @Test + @DisplayName("embedded address can be set to null on update") + void update_clearsEmbeddedAddress() { + var customer = customerWith("Dave", "Some St", "Somewhere", "11111"); + morphium.store(customer); + + customer.setAddress(null); + morphium.store(customer); + + var found = byName("Dave"); + assertThat(found.getAddress()).isNull(); + } + + @Test + @DisplayName("multiple entities with different embedded addresses are independent") + void multipleEntities_embeddedAddressesAreIndependent() { + morphium.store(customerWith("Eve", "Eve St", "Eve City", "10000")); + morphium.store(customerWith("Frank", "Frank Ave", "Frank City", "20000")); + + assertThat(byName("Eve").getAddress().getCity()).isEqualTo("Eve City"); + assertThat(byName("Frank").getAddress().getCity()).isEqualTo("Frank City"); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private CustomerEntity customerWith(String name, String street, String city, String zip) { + var c = new CustomerEntity(); + c.setName(name); + c.setAddress(address(street, city, zip)); + return c; + } + + private AddressEmbedded address(String street, String city, String zip) { + var a = new AddressEmbedded(); + a.setStreet(street); + a.setCity(city); + a.setZip(zip); + return a; + } + + private CustomerEntity byName(String name) { + return morphium.createQueryFor(CustomerEntity.class) + .f("name").eq(name).get(); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumEntityRegistryTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumEntityRegistryTest.java new file mode 100644 index 000000000..6151c8ee4 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumEntityRegistryTest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.AnnotationAndReflectionHelper; +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that the Quarkus build-time entity discovery (Jandex scan in + * {@code MorphiumProcessor}) correctly pre-registers {@code @Entity} and + * {@code @Embedded} classes via {@code AnnotationAndReflectionHelper.registerTypeIds()}. + * + *

    This is an explicit test for the pre-registration flow: + * the Processor discovers entities at build time, the Recorder stores + * class names, and the Producer builds a typeId map and registers it. + */ +@QuarkusTest +@DisplayName("Build-time entity pre-registration (registerTypeIds)") +class MorphiumEntityRegistryTest { + + @Inject + Morphium morphium; + + @Test + @DisplayName("TypeId resolution works for pre-registered @Entity") + void typeIdResolution_worksForEntity() throws Exception { + AnnotationAndReflectionHelper arh = new AnnotationAndReflectionHelper(true); + Class resolved = arh.getClassForTypeId(CustomerEntity.class.getName()); + assertThat(resolved).isEqualTo(CustomerEntity.class); + } + + @Test + @DisplayName("TypeId resolution works for pre-registered @Embedded") + void typeIdResolution_worksForEmbedded() throws Exception { + AnnotationAndReflectionHelper arh = new AnnotationAndReflectionHelper(true); + Class resolved = arh.getClassForTypeId(AddressEmbedded.class.getName()); + assertThat(resolved).isEqualTo(AddressEmbedded.class); + } + + @Test + @DisplayName("ObjectMapper resolves collection name for pre-registered @Entity") + void objectMapper_resolvesCollectionName() { + String collName = morphium.getMapper().getCollectionName(CustomerEntity.class); + assertThat(collName).isEqualTo("it_customers"); + } + + @Test + @DisplayName("ObjectMapper resolves class for pre-registered collection name") + void objectMapper_resolvesClassForCollectionName() { + Class resolved = morphium.getMapper().getClassForCollectionName("it_customers"); + assertThat(resolved).isEqualTo(CustomerEntity.class); + } + + @Test + @DisplayName("TypeId for OrderEntity resolves correctly") + void typeIdResolution_worksForOrderEntity() throws Exception { + AnnotationAndReflectionHelper arh = new AnnotationAndReflectionHelper(true); + Class resolved = arh.getClassForTypeId(OrderEntity.class.getName()); + assertThat(resolved).isEqualTo(OrderEntity.class); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumHealthCheckDisabledTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumHealthCheckDisabledTest.java new file mode 100644 index 000000000..91026c278 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumHealthCheckDisabledTest.java @@ -0,0 +1,65 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.hasItem; + +/** + * Verifies that Morphium health checks are absent when + * {@code quarkus.morphium.health.enabled=false}. + */ +@QuarkusTest +@TestProfile(MorphiumHealthCheckDisabledTest.DisabledHealthProfile.class) +@DisplayName("Morphium health checks (disabled)") +class MorphiumHealthCheckDisabledTest { + + /** + * Test profile that disables Morphium health checks via build-time config. + */ + public static class DisabledHealthProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.morphium.driver-name", "InMemDriver", + "quarkus.morphium.database", "inmem-test", + "quarkus.morphium.devservices.enabled", "false", + "quarkus.morphium.health.enabled", "false" + ); + } + } + + @Test + @DisplayName("GET /q/health -> no Morphium checks present") + void noMorphiumChecksWhenDisabled() { + given() + .when().get("/q/health") + .then() + .statusCode(200) + .body("checks.name", not(hasItem("Morphium liveness check"))) + .body("checks.name", not(hasItem("Morphium readiness check"))) + .body("checks.name", not(hasItem("Morphium startup check"))); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumHealthCheckTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumHealthCheckTest.java new file mode 100644 index 000000000..1ff129740 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumHealthCheckTest.java @@ -0,0 +1,87 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.quarkus.testing.InMemMorphiumTestProfile; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.*; + +/** + * Verifies that Morphium health checks are registered and report UP + * when connected via the InMemDriver. + */ +@QuarkusTest +@TestProfile(InMemMorphiumTestProfile.class) +@DisplayName("Morphium health checks (enabled)") +class MorphiumHealthCheckTest { + + @Test + @DisplayName("GET /q/health/live -> Morphium liveness check UP") + void livenessCheckIsUp() { + given() + .when().get("/q/health/live") + .then() + .statusCode(200) + .body("status", is("UP")) + .body("checks.name", hasItem("Morphium liveness check")) + .body("checks.find { it.name == 'Morphium liveness check' }.status", is("UP")); + } + + @Test + @DisplayName("GET /q/health/ready -> Morphium readiness check UP") + void readinessCheckIsUp() { + given() + .when().get("/q/health/ready") + .then() + .statusCode(200) + .body("status", is("UP")) + .body("checks.name", hasItem("Morphium readiness check")) + .body("checks.find { it.name == 'Morphium readiness check' }.status", is("UP")); + } + + @Test + @DisplayName("GET /q/health/started -> Morphium startup check UP") + void startupCheckIsUp() { + given() + .when().get("/q/health/started") + .then() + .statusCode(200) + .body("status", is("UP")) + .body("checks.name", hasItem("Morphium startup check")) + .body("checks.find { it.name == 'Morphium startup check' }.status", is("UP")); + } + + @Test + @DisplayName("GET /q/health -> all checks contain database metadata") + void healthChecksContainDatabaseMetadata() { + given() + .when().get("/q/health") + .then() + .statusCode(200) + .body("checks.name", hasItems( + "Morphium liveness check", + "Morphium readiness check", + "Morphium startup check")) + .body("checks.find { it.name == 'Morphium liveness check' }.data.database", is("inmem-test")) + .body("checks.find { it.name == 'Morphium readiness check' }.data.database", is("inmem-test")) + .body("checks.find { it.name == 'Morphium startup check' }.data.database", is("inmem-test")); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumIdEntity.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumIdEntity.java new file mode 100644 index 000000000..5e23486d8 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumIdEntity.java @@ -0,0 +1,41 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.annotations.Entity; +import de.caluga.morphium.annotations.Id; +import de.caluga.morphium.annotations.Property; +import de.caluga.morphium.driver.MorphiumId; + +/** + * Test entity whose primary key is a {@link MorphiumId} — the shape that, without + * a JSON customizer, leaks the internal {@code {pid, counter, ...}} struct over REST. + */ +@Entity(collectionName = "it_morphium_id") +public class MorphiumIdEntity { + + @Id + private MorphiumId id; + + @Property(fieldName = "name") + private String name; + + public MorphiumId getId() { return id; } + public void setId(MorphiumId id) { this.id = id; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumIdJsonSerializationTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumIdJsonSerializationTest.java new file mode 100644 index 000000000..60bcbb852 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumIdJsonSerializationTest.java @@ -0,0 +1,113 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; + +import de.caluga.morphium.driver.MorphiumId; +import de.caluga.morphium.quarkus.testing.InMemMorphiumTestProfile; + +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * End-to-end acceptance test for the extension's default {@code MorphiumId} JSON + * handling over a real REST endpoint (using {@code quarkus-rest-jackson}), with + * no user-written serializer anywhere in the application. + * + *

    Reproduces the production bug from the datona-component-library showcase: + * before the customizer, {@code GET /morphium-id/entity/{id}} returned + * {@code "id":{"pid":..,"counter":..,...}}, which collapsed every grid row to the + * same key on the consumer side. + */ +@QuarkusTest +@TestProfile(InMemMorphiumTestProfile.class) +@DisplayName("MorphiumId JSON wire format over REST") +class MorphiumIdJsonSerializationTest { + + @Test + @DisplayName("GET entity -> id is a flat hex string, not the {pid,counter,...} struct") + void entityIdSerializesAsHexString() { + MorphiumId id = new MorphiumId(); + + given() + .when().get("/morphium-id/entity/{id}", id.toString()) + .then() + .statusCode(200) + .body("id", equalTo(id.toString())) + .body("name", equalTo("widget")) + // The internal bean shape must not leak. + .body("id", not(equalTo("[object Object]"))); + } + + @Test + @DisplayName("POST echo/{id} -> hex path param parses into a real MorphiumId") + void pathParamDeserializesFromHexString() { + MorphiumId id = new MorphiumId(); + + String echoed = given() + .when().post("/morphium-id/echo/{id}", id.toString()) + .then() + .statusCode(200) + .extract().asString(); + + // Round-trips via equals: the server reconstructed the same MorphiumId. + org.assertj.core.api.Assertions.assertThat(new MorphiumId(echoed)).isEqualTo(id); + } + + @Test + @DisplayName("POST echo/{malformed-id} does not throw an unhandled exception (RESTEasy Reactive path-param conversion failure)") + void malformedPathParamDoesNotCrashTheServer() { + // @PathParam MorphiumId id is resolved by RESTEasy Reactive's built-in JAX-RS + // String-constructor convention: it calls MorphiumId's public MorphiumId(String) + // constructor directly with the raw path segment, no ParamConverter or Jackson + // involved at all. That constructor throws IllegalArgumentException("no hex string: ...") + // on anything that isn't a 24-character hex string. + // + // Verified (not assumed) what RESTEasy Reactive actually does with that exception: it + // does NOT propagate as an unhandled 500 -- a failed String-constructor path-param + // conversion is treated as "no matching resource method", so the response is 404. That + // is at least not a server-error leak, but it is also not a very informative 400 Bad + // Request for what is actually invalid input, not a missing resource. Documenting the + // real (404) behavior here rather than an unverified assumption of 500. + given() + .when().post("/morphium-id/echo/{id}", "not-a-valid-hex-id") + .then() + .statusCode(404); + } + + @Test + @DisplayName("POST entity with malformed id in JSON body -> 400, not 500") + void malformedJsonBodyIdReturnsBadRequestNotServerError() { + // This is the actual MorphiumIdJacksonModule deserializer path (distinct from the + // @PathParam path above, which never touches Jackson at all). Its deserialize() calls + // new MorphiumId(hex) directly on the raw JSON string value; Jackson wraps that + // IllegalArgumentException as a JsonMappingException during body parsing, and RESTEasy + // Reactive's default exception mapping for a body-parsing failure IS 400 Bad Request + // (verified against the actual response below, not assumed). + given() + .contentType("application/json") + .body("{\"id\":\"not-a-valid-hex-id\",\"name\":\"whatever\"}") + .when().post("/morphium-id/entity") + .then() + .statusCode(400); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumIdResource.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumIdResource.java new file mode 100644 index 000000000..ef7650544 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumIdResource.java @@ -0,0 +1,70 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.driver.MorphiumId; + +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.core.MediaType; + +/** + * Minimal REST resource exercising {@link MorphiumId} on the JSON wire: + *

      + *
    • {@code GET /morphium-id/entity} returns a {@link MorphiumIdEntity} — proves + * outbound serialization emits {@code "id":""} instead of the struct.
    • + *
    • {@code GET /morphium-id/echo/{id}} echoes back a {@code MorphiumId} path + * param — proves inbound deserialization parses the hex string.
    • + *
    + * The extension installs the (de)serializer automatically; this resource writes + * no custom JSON code. + */ +@Path("/morphium-id") +public class MorphiumIdResource { + + @GET + @Path("/entity/{id}") + @Produces(MediaType.APPLICATION_JSON) + public MorphiumIdEntity entity(@PathParam("id") MorphiumId id) { + MorphiumIdEntity e = new MorphiumIdEntity(); + e.setId(id); + e.setName("widget"); + return e; + } + + @POST + @Path("/echo/{id}") + @Produces(MediaType.TEXT_PLAIN) + public String echo(@PathParam("id") MorphiumId id) { + // Returning toString() proves the path param was parsed into a real + // MorphiumId (not left as a raw string) and survives the round-trip. + return id.toString(); + } + + @POST + @Path("/entity") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.TEXT_PLAIN) + public String acceptEntity(MorphiumIdEntity entity) { + // Exercises MorphiumIdJacksonModule's deserializer via the JSON request-body path + // (distinct from the @PathParam String-constructor path both other endpoints use). + return entity.getId().toString(); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumInMemProfileTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumInMemProfileTest.java new file mode 100644 index 000000000..e8867f259 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumInMemProfileTest.java @@ -0,0 +1,98 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.quarkus.testing.InMemMorphiumTestProfile; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.ConfigProvider; +import org.junit.jupiter.api.*; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that {@link InMemMorphiumTestProfile} from the {@code quarkus-morphium-testing} + * module correctly overrides configuration and that all Morphium operations work under + * the profile. + * + *

    This test uses a different Quarkus application context from the default integration + * tests ({@code morphium.database=inmem-test} instead of {@code it-db}). Quarkus restarts + * the context once when switching profiles. + */ +@QuarkusTest +@TestProfile(InMemMorphiumTestProfile.class) +@DisplayName("InMemMorphiumTestProfile (quarkus-morphium-testing)") +class MorphiumInMemProfileTest { + + @Inject + Morphium morphium; + + @Test + @DisplayName("profile overrides quarkus.morphium.database to 'inmem-test'") + void profile_overridesDatabase() { + String db = ConfigProvider.getConfig() + .getValue("quarkus.morphium.database", String.class); + assertThat(db).isEqualTo("inmem-test"); + } + + @Test + @DisplayName("profile keeps quarkus.morphium.driver-name as InMemDriver") + void profile_driverIsInMem() { + String driver = ConfigProvider.getConfig() + .getValue("quarkus.morphium.driver-name", String.class); + assertThat(driver).isEqualTo("InMemDriver"); + } + + @Test + @DisplayName("profile disables Dev Services") + void profile_devServicesDisabled() { + String enabled = ConfigProvider.getConfig() + .getValue("quarkus.morphium.devservices.enabled", String.class); + assertThat(enabled).isEqualTo("false"); + } + + @Test + @DisplayName("Morphium bean is injectable and connected under the profile") + void morphium_isConnected() { + assertThat(morphium).isNotNull(); + assertThat(morphium.getDriver().isConnected()).isTrue(); + } + + @Test + @DisplayName("full CRUD cycle works under InMemMorphiumTestProfile") + void crudCycle_worksUnderProfile() { + morphium.dropCollection(ItemEntity.class); + + var item = new ItemEntity(); + item.setName("inm-profile-item"); + item.setPrice(3.14); + morphium.store(item); + + assertThat(item.getId()).isNotNull(); + assertThat(item.getVersion()).isEqualTo(1L); + + var found = morphium.createQueryFor(ItemEntity.class) + .f("name").eq("inm-profile-item").get(); + assertThat(found).isNotNull(); + assertThat(found.getPrice()).isEqualTo(3.14); + + morphium.delete(found); + assertThat(morphium.createQueryFor(ItemEntity.class) + .f("name").eq("inm-profile-item").get()).isNull(); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumInjectionTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumInjectionTest.java new file mode 100644 index 000000000..8b3795c7f --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumInjectionTest.java @@ -0,0 +1,54 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that the extension correctly produces a {@link Morphium} CDI bean + * and that it is operational (connected to the InMemDriver). + */ +@QuarkusTest +@DisplayName("Morphium CDI injection") +class MorphiumInjectionTest { + + @Inject + Morphium morphium; + + @Test + @DisplayName("Morphium bean is not null") + void morphiumBeanIsProduced() { + assertThat(morphium).isNotNull(); + } + + @Test + @DisplayName("Morphium is connected (InMemDriver reports isConnected=true)") + void morphiumIsConnected() { + assertThat(morphium.getDriver().isConnected()).isTrue(); + } + + @Test + @DisplayName("Morphium uses the configured database name") + void morphiumUsesConfiguredDatabase() { + assertThat(morphium.getConfig().connectionSettings().getDatabase()).isEqualTo("it-db"); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumItemRepository.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumItemRepository.java new file mode 100644 index 000000000..6553e0ab1 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumItemRepository.java @@ -0,0 +1,15 @@ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.data.MorphiumRepository; +import jakarta.data.repository.Repository; + +import java.util.List; + +/** + * Repository extending {@link MorphiumRepository} to test distinct(), morphium() and query() methods. + */ +@Repository +public interface MorphiumItemRepository extends MorphiumRepository { + + List findByTag(String tag); +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumLocalDateTimeTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumLocalDateTimeTest.java new file mode 100644 index 000000000..48f16d643 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumLocalDateTimeTest.java @@ -0,0 +1,132 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for {@code LocalDateTime} storage and retrieval. + * + *

    Verifies that the configured {@code LocalDateTimeMapper} (BSON ISODate by default) + * correctly round-trips Java {@link LocalDateTime} values through the InMemDriver. + */ +@QuarkusTest +@DisplayName("LocalDateTime storage and retrieval") +class MorphiumLocalDateTimeTest { + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.dropCollection(OrderEntity.class); + morphium.ensureIndicesFor(OrderEntity.class); + } + + @Test + @DisplayName("explicit LocalDateTime round-trips correctly") + void roundtrip_explicitValue() { + var timestamp = LocalDateTime.of(2024, 6, 15, 10, 30, 45); + + var order = order("ldt-roundtrip", timestamp); + morphium.store(order); + + var found = byCustomer("ldt-roundtrip"); + assertThat(found).isNotNull(); + assertThat(found.getCreatedAt()).isEqualTo(timestamp); + } + + @Test + @DisplayName("@PreStore sets createdAt when null") + void preStore_setsCreatedAtWhenNull() { + var order = order("ldt-prestoredt", null); + assertThat(order.getCreatedAt()).isNull(); + + morphium.store(order); + + assertThat(order.getCreatedAt()) + .as("@PreStore must have assigned createdAt") + .isNotNull(); + } + + @Test + @DisplayName("createdAt assigned by @PreStore survives the round-trip") + void preStore_createdAt_survivesRoundtrip() { + var order = order("ldt-prestoredt-rt", null); + morphium.store(order); + + LocalDateTime storedAt = order.getCreatedAt(); + assertThat(storedAt).isNotNull(); + + var found = byCustomer("ldt-prestoredt-rt"); + assertThat(found.getCreatedAt()) + .as("createdAt from @PreStore must be preserved in the store") + .isNotNull() + .isEqualTo(storedAt.truncatedTo(java.time.temporal.ChronoUnit.MILLIS)); + } + + @Test + @DisplayName("date and time components are preserved") + void componentsPreserved() { + var timestamp = LocalDateTime.of(2024, 3, 14, 15, 9, 26); + + morphium.store(order("ldt-components", timestamp)); + + var found = byCustomer("ldt-components"); + assertThat(found.getCreatedAt()) + .hasYear(2024) + .hasMonth(java.time.Month.MARCH) + .hasDayOfMonth(14) + .hasHour(15) + .hasMinute(9) + .hasSecond(26); + } + + @Test + @DisplayName("midnight (00:00:00) is stored and retrieved correctly") + void midnight_roundtrip() { + var midnight = LocalDateTime.of(2024, 1, 1, 0, 0, 0); + + morphium.store(order("ldt-midnight", midnight)); + + var found = byCustomer("ldt-midnight"); + assertThat(found.getCreatedAt()).isEqualTo(midnight); + } + + // ── helpers ────────────────────────────────────────────────────────────── + + private OrderEntity order(String customerId, LocalDateTime createdAt) { + var o = new OrderEntity(); + o.setCustomerId(customerId); + o.setAmount(1.0); + o.setStatus("OPEN"); + o.setCreatedAt(createdAt); + return o; + } + + private OrderEntity byCustomer(String customerId) { + return morphium.createQueryFor(OrderEntity.class) + .f("customer_id").eq(customerId) + .get(); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumMigrationTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumMigrationTest.java new file mode 100644 index 000000000..811978bc8 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumMigrationTest.java @@ -0,0 +1,422 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.quarkus.migration.MorphiumMigrationConfig; +import de.caluga.morphium.quarkus.migration.MorphiumMigrationEntry; +import de.caluga.morphium.quarkus.migration.MorphiumMigrationLock; +import de.caluga.morphium.quarkus.migration.MorphiumMigrationRunner; +import de.caluga.morphium.query.Query; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import java.util.Date; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Integration test for the Morphium migration framework. + * Tests programmatic migration execution using {@link MorphiumMigrationRunner}; + * the migrate-at-start flag in {@code TestMigrationConfig} is not used in this test. + */ +@QuarkusTest +@DisplayName("Morphium Migration Framework") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumMigrationTest { + + @Inject + Morphium morphium; + + private static final String CHANGELOG_COLLECTION = "testChangeLog"; + private static final String LOCK_COLLECTION = "testMigrationLock"; + + private MorphiumMigrationRunner runner; + + @BeforeEach + void setUp() { + runner = new MorphiumMigrationRunner(morphium, new TestMigrationConfig()); + } + + @Test + @Order(1) + @DisplayName("Migrations execute in order and create changelog entries") + void migrationsExecuteAndTrack() { + // Clean up from potential previous runs + morphium.dropCollection(MorphiumMigrationEntry.class, CHANGELOG_COLLECTION, null); + morphium.dropCollection(MorphiumMigrationLock.class, LOCK_COLLECTION, null); + morphium.dropCollection(ItemEntity.class); + + List migrations = List.of( + InitItemsMigration.class.getName(), + AddCategoryMigration.class.getName() + ); + + runner.execute(migrations); + + // Verify changelog entries + Query q = morphium.createQueryFor(MorphiumMigrationEntry.class); + q.setCollectionName(CHANGELOG_COLLECTION); + q.sort("order"); + List entries = q.asList(); + + assertThat(entries).hasSize(2); + + assertThat(entries.get(0).getChangeId()).isEqualTo("001-init-items"); + assertThat(entries.get(0).getState()).isEqualTo(MorphiumMigrationEntry.ChangeState.EXECUTED); + assertThat(entries.get(0).getAuthor()).isEqualTo("test"); + assertThat(entries.get(0).getExecutionTimeMs()).isGreaterThanOrEqualTo(0); + + assertThat(entries.get(1).getChangeId()).isEqualTo("002-add-category"); + assertThat(entries.get(1).getState()).isEqualTo(MorphiumMigrationEntry.ChangeState.EXECUTED); + } + + @Test + @Order(2) + @DisplayName("Migrations actually modify the database") + void migrationsModifyDatabase() { + Query q = morphium.createQueryFor(ItemEntity.class); + q.f("tag").in(List.of("migration-v1", "migration-v2")); + List items = q.asList(); + + assertThat(items).hasSizeGreaterThanOrEqualTo(2); + assertThat(items).extracting(ItemEntity::getName) + .contains("Migrated Widget", "Migrated Gadget"); + } + + @Test + @Order(3) + @DisplayName("Already executed migrations are skipped on re-run") + void alreadyExecutedMigrationsAreSkipped() { + // Count items before second run + long countBefore = morphium.createQueryFor(ItemEntity.class) + .f("tag").in(List.of("migration-v1", "migration-v2")) + .countAll(); + + // Re-run the same migrations + List migrations = List.of( + InitItemsMigration.class.getName(), + AddCategoryMigration.class.getName() + ); + runner.execute(migrations); + + // Count items after — should be same (no duplicates) + long countAfter = morphium.createQueryFor(ItemEntity.class) + .f("tag").in(List.of("migration-v1", "migration-v2")) + .countAll(); + + assertThat(countAfter).isEqualTo(countBefore); + + // Changelog should still have exactly 2 entries + Query q = morphium.createQueryFor(MorphiumMigrationEntry.class); + q.setCollectionName(CHANGELOG_COLLECTION); + assertThat(q.countAll()).isEqualTo(2); + } + + @Test + @Order(4) + @DisplayName("Lock is released after migrations complete") + void lockIsReleasedAfterMigrations() { + Query q = morphium.createQueryFor(MorphiumMigrationLock.class); + q.setCollectionName(LOCK_COLLECTION); + assertThat(q.countAll()).isZero(); + } + + @Test + @Order(5) + @DisplayName("Empty migration list is handled gracefully") + void emptyMigrationList() { + // Should not throw + runner.execute(List.of()); + } + + @Test + @Order(6) + @DisplayName("Failed migration triggers rollback and records ROLLED_BACK state") + void failedMigrationTriggersRollback() { + FailingMigration.rollbackExecuted = false; + + assertThatThrownBy(() -> runner.execute(List.of(FailingMigration.class.getName()))) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("999-failing"); + + // Verify rollback was executed + assertThat(FailingMigration.rollbackExecuted).isTrue(); + + // Verify changelog entry has ROLLED_BACK state + Query q = morphium.createQueryFor(MorphiumMigrationEntry.class); + q.setCollectionName(CHANGELOG_COLLECTION); + q.f("_id").eq("999-failing"); + MorphiumMigrationEntry entry = q.get(); + + assertThat(entry).isNotNull(); + assertThat(entry.getState()).isEqualTo(MorphiumMigrationEntry.ChangeState.ROLLED_BACK); + + // Verify lock is released even after failure + Query lockQ = morphium.createQueryFor(MorphiumMigrationLock.class); + lockQ.setCollectionName(LOCK_COLLECTION); + assertThat(lockQ.countAll()).isZero(); + } + + // -- Regression: lock TTL renewal (merge blocker #6) -- + // + // Why this does NOT use the seemingly obvious "a concurrent contender's acquireLock() must + // fail" approach: InMemoryDriver's upsert path, when its filter (owner-agnostic, matching + // only an expired/absent lock) matches zero documents, seeds a replacement from the + // equality predicates (just _id, correctly mirroring real MongoDB) and routes it through + // storeInternal(). storeInternal() treats an already-existing _id there as a plain replace + // (remove + insert) rather than raising a duplicate-key error -- unlike its own + // insertInternal() path, which does implement that check correctly, but which the upsert + // never reaches. Consequently any contender can steal a still-valid, still-renewed lock + // under InMemoryDriver regardless of renewal, making acquireLock() non-atomic there (though + // correctly atomic against a real MongoDB server). A contender-based test is therefore not + // just flaky but structurally unable to prove anything on this driver. + // + // These two tests instead prove renewal directly and positively: while the real migration + // workload runs on a background thread, the main test thread observes the lock document + // (read straight from config.lockCollection() via MorphiumMigrationRunner.getLockId(), since + // releaseLock() deletes it once the run finishes) at two defined points in time DURING the + // run, and asserts that (1) the owner is unchanged -- no takeover -- and (2) expires_at has + // moved strictly forward between the two measurements, which is only possible if + // renewLock() (directly, or via the in-flight heartbeat) actually executed in between. + // + // (a) renewLockBetweenChangeUnitsAdvancesExpiry: measures once during the first change + // unit and once during the second, straddling the boundary between them, with a TTL + // long enough that the in-flight heartbeat's tick interval exceeds either unit's + // sleep -- so only the between-units renewLock() call in the execute() loop can move + // expires_at here. + // + // (b) inFlightHeartbeatAdvancesExpiryDuringSingleUnit: both measurements are taken WHILE a + // single, long-running change unit is still executing -- there is no "between units" + // boundary at all until that one unit returns, so only the in-flight heartbeat started + // inside executeMigration() can be responsible for any forward movement observed here. + // + // Both use generous (hundreds of ms) margins around every measurement/renewal boundary to + // avoid flaky timing races while keeping total runtime in the low single-digit seconds. + + /** Reads the single migration-lock document directly, or {@code null} if not currently held. */ + private MorphiumMigrationLock readLockDocument() { + Query q = morphium.createQueryFor(MorphiumMigrationLock.class); + q.setCollectionName(LOCK_COLLECTION); + q.f("_id").eq(MorphiumMigrationRunner.getLockId()); + return q.get(); + } + + @Test + @Order(7) + @DisplayName("renewLock() between change units advances expires_at without changing the owner") + void renewLockBetweenChangeUnitsAdvancesExpiry() throws Exception { + morphium.dropCollection(MorphiumMigrationEntry.class, CHANGELOG_COLLECTION, null); + morphium.dropCollection(MorphiumMigrationLock.class, LOCK_COLLECTION, null); + + // Long (10s) TTL relative to the unit sleeps below: the in-flight heartbeat's tick + // interval (~TTL/3, here ~3.3s, floored at 200ms) is far longer than either unit's + // 600ms sleep, so it cannot fire during either one. The only thing that can move + // expires_at forward between this test's two measurements is the renewLock() call + // between units. + var config = new TestMigrationConfig() { + @Override public int lockTtlSeconds() { return 10; } + }; + var slowRunner = new MorphiumMigrationRunner(morphium, config); + + SlowMigration.SLEEP_MS = 600L; + SlowMigration2.SLEEP_MS = 600L; + try { + AtomicReference workerFailure = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + slowRunner.execute(List.of(SlowMigration.class.getName(), SlowMigration2.class.getName(), + AddCategoryMigration.class.getName())); + } catch (Throwable t) { + workerFailure.set(t); + } + }); + worker.start(); + + // t=300ms: comfortably inside the first unit (600ms total), well before it returns + // and therefore well before the renewLock() call that only happens once it does. + Thread.sleep(300L); + MorphiumMigrationLock during1 = readLockDocument(); + assertThat(during1).as("lock document must exist while migrations are running").isNotNull(); + + // t=900ms: 300ms into the second unit (which started at ~600ms) -- comfortably + // AFTER the renewLock() call that ran between the two units (~600ms) and + // comfortably BEFORE the second unit itself finishes (~1200ms). + Thread.sleep(600L); + MorphiumMigrationLock during2 = readLockDocument(); + assertThat(during2).as("lock document must still exist while migrations are running").isNotNull(); + + worker.join(5000L); + assertThat(worker.isAlive()).as("migration worker thread should have finished").isFalse(); + assertThat(workerFailure.get()).as("migration run must have completed without error").isNull(); + + assertThat(during2.getOwner()) + .as("owner must be unchanged between the two measurements -- no takeover happened") + .isEqualTo(during1.getOwner()); + assertThat(during2.getExpiresAt()) + .as("expires_at must have been pushed forward by the between-units renewLock() call") + .isAfter(during1.getExpiresAt()); + + Query q = morphium.createQueryFor(MorphiumMigrationEntry.class); + q.setCollectionName(CHANGELOG_COLLECTION); + q.f("_id").eq("002-add-category"); + assertThat(q.get()).isNotNull(); + + // Lock released at the end of a successful run. + Query lockQ = morphium.createQueryFor(MorphiumMigrationLock.class); + lockQ.setCollectionName(LOCK_COLLECTION); + assertThat(lockQ.countAll()).isZero(); + } finally { + SlowMigration.SLEEP_MS = 1500L; + SlowMigration2.SLEEP_MS = 1500L; + } + } + + @Test + @Order(8) + @DisplayName("In-flight lock heartbeat advances expires_at during a single long-running change unit") + void inFlightHeartbeatAdvancesExpiryDuringSingleUnit() throws Exception { + morphium.dropCollection(MorphiumMigrationEntry.class, CHANGELOG_COLLECTION, null); + morphium.dropCollection(MorphiumMigrationLock.class, LOCK_COLLECTION, null); + + // Short (1s) TTL: the in-flight heartbeat's tick interval (~333ms with this TTL) is far + // shorter than the single unit's 2s sleep below, so it ticks several times while that + // one unit is still running. Both measurements are taken WHILE this single unit is + // executing, so renewLock() in the execute() loop cannot be responsible for anything + // observed here -- there is no "between units" until this one unit returns. + var config = new TestMigrationConfig() { + @Override public int lockTtlSeconds() { return 1; } + }; + var slowRunner = new MorphiumMigrationRunner(morphium, config); + + SlowMigration2.SLEEP_MS = 2000L; + try { + AtomicReference workerFailure = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + slowRunner.execute(List.of(SlowMigration2.class.getName(), AddCategoryMigration.class.getName())); + } catch (Throwable t) { + workerFailure.set(t); + } + }); + worker.start(); + + // t=600ms: well after the heartbeat's first tick (fires ~333ms after the unit + // starts, given the 1s TTL and its floor-adjusted ~333ms interval), well before the + // unit itself finishes at ~2000ms. + Thread.sleep(600L); + MorphiumMigrationLock during1 = readLockDocument(); + assertThat(during1).as("lock document must exist while the unit is still running").isNotNull(); + + // t=1600ms: a full second later -- several more heartbeat ticks have had the chance + // to fire in between (~333ms interval), still comfortably before the unit finishes + // (~2000ms). + Thread.sleep(1000L); + MorphiumMigrationLock during2 = readLockDocument(); + assertThat(during2).as("lock document must still exist while the unit is still running").isNotNull(); + + worker.join(5000L); + assertThat(worker.isAlive()).as("migration worker thread should have finished").isFalse(); + assertThat(workerFailure.get()).as("migration run must have completed without error").isNull(); + + assertThat(during2.getOwner()) + .as("owner must be unchanged between the two measurements -- no takeover happened") + .isEqualTo(during1.getOwner()); + assertThat(during2.getExpiresAt()) + .as("expires_at must have been pushed forward by the in-flight heartbeat while the unit " + + "was still executing") + .isAfter(during1.getExpiresAt()); + + Query q = morphium.createQueryFor(MorphiumMigrationEntry.class); + q.setCollectionName(CHANGELOG_COLLECTION); + q.f("_id").eq("002-add-category"); + assertThat(q.get()).isNotNull(); + + Query lockQ = morphium.createQueryFor(MorphiumMigrationLock.class); + lockQ.setCollectionName(LOCK_COLLECTION); + assertThat(lockQ.countAll()).isZero(); + } finally { + SlowMigration2.SLEEP_MS = 1500L; + } + } + + @Test + @Order(9) + @DisplayName("acquireLockWithWait: waits for a held lock instead of failing immediately") + void acquireLockWaitsForHeldLock() throws Exception { + morphium.dropCollection(MorphiumMigrationLock.class, LOCK_COLLECTION, null); + + // Manually hold the lock, simulating another instance already running migrations. + // Uses MorphiumMigrationRunner.getLockId() -- the real lock-document id -- rather than + // a copy-pasted string literal, so that renaming MorphiumMigrationRunner.LOCK_ID cannot + // silently make this test blind to its own bugs by upserting a different (unrelated) + // lock document than the one acquireLock() actually reads and writes. + MorphiumMigrationLock heldLock = new MorphiumMigrationLock(); + heldLock.setId(MorphiumMigrationRunner.getLockId()); + heldLock.setOwner("other-instance"); + heldLock.setAcquiredAt(new Date()); + heldLock.setExpiresAt(new Date(System.currentTimeMillis() + 5000L)); + morphium.store(heldLock, LOCK_COLLECTION, null); + + // Release it from a background thread after a short delay, simulating the other + // instance finishing its migration run. + Thread releaser = new Thread(() -> { + try { + Thread.sleep(500L); + } catch (InterruptedException ignored) { + return; + } + Query q = morphium.createQueryFor(MorphiumMigrationLock.class); + q.setCollectionName(LOCK_COLLECTION); + q.f("_id").eq(MorphiumMigrationRunner.getLockId()); + morphium.delete(q); + }); + releaser.start(); + + var waitingConfig = new TestMigrationConfig() { + @Override public int lockWaitSeconds() { return 5; } + }; + var waitingRunner = new MorphiumMigrationRunner(morphium, waitingConfig); + + // Must NOT throw: waits past the releaser's delete, then successfully acquires the + // lock. Uses a real migration (not an empty list) -- execute() with an empty list + // returns before ever calling acquireLockWithWait(), which would make this test + // pass trivially without exercising the wait logic at all. + waitingRunner.execute(List.of(AddCategoryMigration.class.getName())); + releaser.join(); + } + + // ------------------------------------------------------------------ + // Test config with isolated collection names + // ------------------------------------------------------------------ + + private static class TestMigrationConfig implements MorphiumMigrationConfig { + @Override public boolean migrateAtStart() { return true; } + @Override public String changeLogCollection() { return CHANGELOG_COLLECTION; } + @Override public String lockCollection() { return LOCK_COLLECTION; } + @Override public int lockTtlSeconds() { return 30; } + @Override public int lockWaitSeconds() { return 0; } + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumQueryTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumQueryTest.java new file mode 100644 index 000000000..d1d38650e --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumQueryTest.java @@ -0,0 +1,169 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.*; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Integration tests for Morphium query operations: filtering, sorting, pagination, + * count, and the {@code in()} operator. All tests run against the InMemDriver. + */ +@QuarkusTest +@DisplayName("Morphium query operations") +class MorphiumQueryTest { + + @Inject + Morphium morphium; + + @BeforeEach + void setUp() { + morphium.dropCollection(OrderEntity.class); + morphium.ensureIndicesFor(OrderEntity.class); + store("C1", 100.0, "OPEN"); + store("C2", 200.0, "OPEN"); + store("C3", 50.0, "CLOSED"); + store("C1", 300.0, "CLOSED"); + } + + @Test + @DisplayName("f().eq() filters by a single field value") + void filterByCustomer() { + var results = morphium.createQueryFor(OrderEntity.class) + .f("customer_id").eq("C1").asList(); + + assertThat(results).hasSize(2) + .allSatisfy(o -> assertThat(o.getCustomerId()).isEqualTo("C1")); + } + + @Test + @DisplayName("f().eq() on status filters correctly") + void filterByStatus() { + var open = morphium.createQueryFor(OrderEntity.class) + .f("status").eq("OPEN").asList(); + + assertThat(open).hasSize(2) + .allSatisfy(o -> assertThat(o.getStatus()).isEqualTo("OPEN")); + } + + @Test + @DisplayName("f().gt() returns only items with amount > threshold") + void filterByAmountGreaterThan() { + var results = morphium.createQueryFor(OrderEntity.class) + .f("amount").gt(150.0).asList(); + + assertThat(results).hasSize(2) + .allSatisfy(o -> assertThat(o.getAmount()).isGreaterThan(150.0)); + } + + @Test + @DisplayName("f().lt() returns only items with amount < threshold") + void filterByAmountLessThan() { + var results = morphium.createQueryFor(OrderEntity.class) + .f("amount").lt(100.0).asList(); + + assertThat(results).hasSize(1) + .first().satisfies(o -> assertThat(o.getAmount()).isEqualTo(50.0)); + } + + @Test + @DisplayName("sort() ascending orders results by amount") + void sortAscendingByAmount() { + var sorted = morphium.createQueryFor(OrderEntity.class) + .sort("amount").asList(); + + assertThat(sorted).extracting(OrderEntity::getAmount) + .containsExactly(50.0, 100.0, 200.0, 300.0); + } + + @Test + @DisplayName("sort() descending orders results by amount") + void sortDescendingByAmount() { + var sorted = morphium.createQueryFor(OrderEntity.class) + .sort("-amount").asList(); + + assertThat(sorted).extracting(OrderEntity::getAmount) + .containsExactly(300.0, 200.0, 100.0, 50.0); + } + + @Test + @DisplayName("limit() restricts the result count") + void limitResults() { + var limited = morphium.createQueryFor(OrderEntity.class) + .sort("amount").limit(2).asList(); + + assertThat(limited).hasSize(2); + assertThat(limited.get(0).getAmount()).isEqualTo(50.0); + assertThat(limited.get(1).getAmount()).isEqualTo(100.0); + } + + @Test + @DisplayName("skip() skips the first N results") + void skipResults() { + var paged = morphium.createQueryFor(OrderEntity.class) + .sort("amount").skip(2).asList(); + + assertThat(paged).hasSize(2); + assertThat(paged.get(0).getAmount()).isEqualTo(200.0); + assertThat(paged.get(1).getAmount()).isEqualTo(300.0); + } + + @Test + @DisplayName("countAll() on a filtered query returns the matching count") + void countFiltered() { + long count = morphium.createQueryFor(OrderEntity.class) + .f("status").eq("CLOSED").countAll(); + + assertThat(count).isEqualTo(2); + } + + @Test + @DisplayName("f().in() matches any of the given values") + void inOperator() { + var results = morphium.createQueryFor(OrderEntity.class) + .f("customer_id").in(List.of("C1", "C3")).asList(); + + // C1 has 2 orders, C3 has 1 + assertThat(results).hasSize(3) + .allSatisfy(o -> assertThat(o.getCustomerId()).isIn("C1", "C3")); + } + + @Test + @DisplayName("query on empty collection returns empty list") + void emptyCollectionReturnsEmptyList() { + morphium.dropCollection(OrderEntity.class); + + var results = morphium.createQueryFor(OrderEntity.class).asList(); + + assertThat(results).isEmpty(); + } + + // ── helper ─────────────────────────────────────────────────────────────── + + private void store(String customerId, double amount, String status) { + var order = new OrderEntity(); + order.setCustomerId(customerId); + order.setAmount(amount); + order.setStatus(status); + morphium.store(order); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumTransactionalTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumTransactionalTest.java new file mode 100644 index 000000000..b2b52f28f --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumTransactionalTest.java @@ -0,0 +1,192 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.quarkus.transaction.MorphiumTransactionEvent; +import de.caluga.morphium.quarkus.transaction.MorphiumTransactionEvent.Phase; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.QuarkusTestProfile; +import io.quarkus.test.junit.TestProfile; +import jakarta.inject.Inject; +import org.eclipse.microprofile.config.ConfigProvider; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Integration tests for {@code @MorphiumTransactional} interceptor and + * transaction lifecycle events. + * + *

    Transactions require a MongoDB replica set. This test uses Dev Services + * with {@code quarkus.morphium.devservices.replica-set=true} to start a + * single-node replica set via Testcontainers. + * + *

    {@link DockerAvailableCondition}, registered via {@code @ExtendWith} below, checks + * {@code DockerClientFactory.instance().isDockerAvailable()} directly and disables the whole + * class — with a clear message — when no Docker daemon is reachable, instead of failing the + * whole {@code integration-tests} build: a build without a Docker daemon must still be able to + * complete, so this class opts itself out rather than breaking the module. + * + *

    This must be an {@link org.junit.jupiter.api.extension.ExecutionCondition}, not a + * {@code @BeforeAll} assumption: {@code @QuarkusTest} boots the application (attempting to + * connect to MongoDB) inside {@code QuarkusTestExtension}'s own {@code beforeAll} callback, + * which JUnit always runs before the test class's {@code @BeforeAll} methods. By the time a + * {@code @BeforeAll} check would run, the boot attempt — and, without Docker, its failure — + * has already happened. An {@code ExecutionCondition} is evaluated ahead of that. + * + *

    Deliberately not using {@code testcontainers-junit-jupiter}'s + * {@code @EnabledIfDockerAvailable}: under Quarkus's test classloading, + * that annotation's {@code DockerAvailableDetector} reported Docker as unavailable + * and skipped every test even while Dev Services had already started a real + * MongoDB container in the same JVM (confirmed via the surefire report showing a + * successfully started container immediately before the "Docker is not available" + * skip). Calling {@code DockerClientFactory.instance().isDockerAvailable()} + * directly — the same class Dev Services itself uses — avoids that discrepancy. + */ +@QuarkusTest +@ExtendWith(DockerAvailableCondition.class) +@TestProfile(MorphiumTransactionalTest.ReplicaSetProfile.class) +@DisplayName("@MorphiumTransactional interceptor + events") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class MorphiumTransactionalTest { + + public static class ReplicaSetProfile implements QuarkusTestProfile { + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.morphium.database", "tx-test", + "quarkus.morphium.driver-name", "PooledDriver", + "quarkus.morphium.devservices.enabled", "true", + "quarkus.morphium.devservices.replica-set", "true" + ); + } + } + + @Inject + Morphium morphium; + + @Inject + TransactionalService service; + + @Inject + TransactionEventCollector eventCollector; + + @BeforeEach + void clearEvents() { + eventCollector.clear(); + } + + @Test + @Order(0) + @DisplayName("Dev Services actually started a container: hosts is a container port, driver reports replicaSet=true") + void devServicesStartedARealContainer() { + // This is the one thing no other Dev Services test in this suite actually proves: + // MorphiumDevServicesReplicaSetConfigTest explicitly documents that it starts no + // container at all (only checks the config keys are bound), and this class's own + // other tests only prove transactions work -- which happens to require a replica set, + // but doesn't directly show a container was started for it. Verified here instead: + // hosts must be a real container-assigned port (Testcontainers never binds to 27017 + // itself), and the driver must report isReplicaSet()==true, which only a real + // MongoDB replica set negotiates during the driver handshake (an unconfigured + // standalone mongod would report false). + String hosts = ConfigProvider.getConfig().getValue("quarkus.morphium.hosts", String.class); + assertThat(hosts).as("hosts must be injected by Dev Services, not left at the @WithDefault") + .isNotEqualTo("localhost:27017"); + int port = Integer.parseInt(hosts.substring(hosts.indexOf(':') + 1)); + assertThat(port).as("Dev Services assigns a random container port, not the standard 27017") + .isNotEqualTo(27017); + + assertThat(morphium.getDriver().isReplicaSet()) + .as("driver must have negotiated replica-set mode with the real container") + .isTrue(); + } + + @Test + @Order(1) + @DisplayName("commit on success – entity is persisted") + void commit_onSuccess() { + var item = new ItemEntity(); + item.setName("tx-success"); + item.setPrice(42.0); + + service.storeSuccessfully(item); + + ItemEntity found = morphium.createQueryFor(ItemEntity.class) + .f("name").eq("tx-success") + .get(); + assertThat(found).isNotNull(); + assertThat(found.getPrice()).isEqualTo(42.0); + } + + @Test + @Order(2) + @DisplayName("rollback on exception – entity is NOT persisted") + void rollback_onException() { + var item = new ItemEntity(); + item.setName("tx-fail"); + item.setPrice(99.0); + + assertThatThrownBy(() -> service.storeAndFail(item)) + .isInstanceOf(RuntimeException.class) + .hasMessage("forced rollback"); + + ItemEntity found = morphium.createQueryFor(ItemEntity.class) + .f("name").eq("tx-fail") + .get(); + assertThat(found).isNull(); + } + + @Test + @Order(3) + @DisplayName("BEFORE_COMMIT + AFTER_COMMIT events fired on success") + void events_firedOnCommit() { + var item = new ItemEntity(); + item.setName("tx-events-commit"); + + service.storeSuccessfully(item); + + assertThat(eventCollector.getEvents()) + .extracting(MorphiumTransactionEvent::getPhase) + .containsExactly(Phase.BEFORE_COMMIT, Phase.AFTER_COMMIT); + + assertThat(eventCollector.getEvents()) + .allSatisfy(e -> assertThat(e.getFailure()).isNull()); + } + + @Test + @Order(4) + @DisplayName("AFTER_ROLLBACK event fired on exception, with failure") + void events_firedOnRollback() { + var item = new ItemEntity(); + item.setName("tx-events-rollback"); + + assertThatThrownBy(() -> service.storeAndFail(item)) + .isInstanceOf(RuntimeException.class); + + assertThat(eventCollector.getEvents()) + .extracting(MorphiumTransactionEvent::getPhase) + .containsExactly(Phase.AFTER_ROLLBACK); + + assertThat(eventCollector.getEvents().get(0).getFailure()) + .isInstanceOf(RuntimeException.class) + .hasMessage("forced rollback"); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumVersionTest.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumVersionTest.java new file mode 100644 index 000000000..28349e6c7 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/MorphiumVersionTest.java @@ -0,0 +1,120 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.VersionMismatchException; +import io.quarkus.test.junit.QuarkusTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Tests for Morphium's {@code @Version} / optimistic locking support. + * All scenarios use the InMemDriver – no MongoDB required. + */ +@QuarkusTest +@DisplayName("@Version / optimistic locking") +class MorphiumVersionTest { + + @Inject + Morphium morphium; + + @Test + @DisplayName("First store() sets version to 1") + void firstStore_setsVersionToOne() { + var item = new ItemEntity(); + item.setName("v-item-first"); + morphium.store(item); + + assertThat(item.getVersion()) + .as("version must be 1 after first store") + .isEqualTo(1L); + + ItemEntity reloaded = morphium.createQueryFor(ItemEntity.class) + .f("name").eq("v-item-first").get(); + assertThat(reloaded.getVersion()).isEqualTo(1L); + } + + @Test + @DisplayName("Second store() increments version to 2") + void secondStore_incrementsVersion() { + var item = new ItemEntity(); + item.setName("v-item-second"); + morphium.store(item); + assertThat(item.getVersion()).isEqualTo(1L); + + item.setPrice(42.0); + morphium.store(item); + + assertThat(item.getVersion()).isEqualTo(2L); + } + + @Test + @DisplayName("Stale entity (version mismatch) throws VersionMismatchException") + void staleEntity_throwsVersionMismatchException() { + var item = new ItemEntity(); + item.setName("v-item-stale"); + morphium.store(item); // version → 1 + + // Simulate a second client updating the same entity + ItemEntity copy = morphium.createQueryFor(ItemEntity.class) + .f("name").eq("v-item-stale").get(); + copy.setPrice(1.0); + morphium.store(copy); // version → 2 in DB + + // Original reference still has version=1 → must fail + item.setPrice(2.0); + assertThatThrownBy(() -> morphium.store(item)) + .isInstanceOf(VersionMismatchException.class) + .satisfies(ex -> { + var vme = (VersionMismatchException) ex; + assertThat(vme.getExpectedVersion()).isEqualTo(1L); + }); + } + + @Test + @DisplayName("Entity without @Version stores and updates normally") + void entityWithoutVersion_worksNormally() { + // UnversionedEntity has no @Version field at all, so Morphium must not + // perform any optimistic-locking check on store()/update(). + var item = new UnversionedEntity(); + item.setName("v-item-noversion"); + morphium.store(item); + + String id = item.getId(); + assertThat(id).as("id must be assigned after first store").isNotNull(); + + // A concurrent "second client" loads and updates the same entity first... + UnversionedEntity concurrent = morphium.createQueryFor(UnversionedEntity.class) + .f("name").eq("v-item-noversion").get(); + concurrent.setPrice(1.0); + morphium.store(concurrent); + + // ...and the original in-memory reference (now stale w.r.t. price) must still + // store without any VersionMismatchException, because there is no version + // to check. + item.setPrice(2.0); + morphium.store(item); + + UnversionedEntity reloaded = morphium.createQueryFor(UnversionedEntity.class) + .f("id").eq(id).get(); + assertThat(reloaded.getPrice()).isEqualTo(2.0); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/OrderEntity.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/OrderEntity.java new file mode 100644 index 000000000..75e8c1635 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/OrderEntity.java @@ -0,0 +1,76 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.annotations.*; +import de.caluga.morphium.annotations.lifecycle.*; +import java.time.LocalDateTime; +import java.util.List; + +/** + * Test entity used in query and LocalDateTime integration tests. + */ +@Entity(collectionName = "it_orders") +@Lifecycle +public class OrderEntity { + + @Id + private String id; + + @Property(fieldName = "customer_id") + private String customerId; + + @Property(fieldName = "amount") + private double amount; + + @Property(fieldName = "status") + private String status; + + @Property(fieldName = "created_at") + private LocalDateTime createdAt; + + @Property(fieldName = "tags") + private List tags; + + @Property(fieldName = "urgent") + private boolean urgent; + + @Version + @Property(fieldName = "version") + private long version; + + @PreStore + public void onStore() { + if (createdAt == null) createdAt = LocalDateTime.now(); + } + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getCustomerId() { return customerId; } + public void setCustomerId(String c) { this.customerId = c; } + public double getAmount() { return amount; } + public void setAmount(double a) { this.amount = a; } + public String getStatus() { return status; } + public void setStatus(String s) { this.status = s; } + public LocalDateTime getCreatedAt() { return createdAt; } + public void setCreatedAt(LocalDateTime d) { this.createdAt = d; } + public List getTags() { return tags; } + public void setTags(List t) { this.tags = t; } + public boolean isUrgent() { return urgent; } + public void setUrgent(boolean u) { this.urgent = u; } + public long getVersion() { return version; } + public void setVersion(long v) { this.version = v; } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/OrderRepository.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/OrderRepository.java new file mode 100644 index 000000000..201a722aa --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/OrderRepository.java @@ -0,0 +1,335 @@ +package de.caluga.morphium.quarkus.it; + +import jakarta.data.repository.BasicRepository; +import jakarta.data.repository.Delete; +import jakarta.data.repository.Find; +import jakarta.data.repository.By; +import jakarta.data.repository.OrderBy; +import jakarta.data.repository.Param; +import jakarta.data.repository.Query; +import jakarta.data.repository.Repository; + +import jakarta.data.Limit; +import jakarta.data.Sort; +import jakarta.data.page.Page; +import jakarta.data.page.PageRequest; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletionStage; +import java.util.stream.Stream; + +/** + * Jakarta Data repository for {@link OrderEntity}. + * Tests query derivation with various operators and JDQL queries. + */ +@Repository +public interface OrderRepository extends BasicRepository { + + // -- Phase 2: Query derivation methods -- + + List findByStatus(String status); + + List findByAmountGreaterThan(double minAmount); + + List findByAmountGreaterThanEqual(double minAmount); + + List findByAmountLessThan(double maxAmount); + + List findByStatusAndAmountGreaterThan(String status, double minAmount); + + long countByStatus(String status); + + boolean existsByStatus(String status); + + // -- Regression: dynamic Sort/Limit/PageRequest parameters on a derived findBy* method + // (previously silently ignored -- QueryMethodBridge had no mechanism to detect or apply + // them, unlike the @Find path via FindMethodBridge) -- + + List findByStatus(String status, Sort sort); + + List findByStatus(String status, Limit limit); + + Page findByStatus(String status, PageRequest pageRequest); + + // -- Regression: dynamic Sort parameter on a derived deleteBy*/countBy*/existsBy* method + // (previously fell through to the FIND branch of QueryMethodBridge#executeQuery, so + // deleteByStatus(Sort) deleted nothing while still returning a "successful" count, and + // countByStatus(Sort)/existsByStatus(Sort) threw a ClassCastException because a List came + // back where the generated bytecode expected a Long/boolean) -- + + long deleteByStatus(String status, Sort sort); + + long countByStatus(String status, Sort sort); + + boolean existsByStatus(String status, Sort sort); + + // -- Phase 5: @Query with JDQL -- + + @Query("WHERE status = :status ORDER BY amount ASC") + List queryByStatus(@Param("status") String status); + + @Query("WHERE status = :status AND amount > :minAmount") + List queryByStatusAndMinAmount(@Param("status") String status, + @Param("minAmount") double minAmount); + + @Query("WHERE amount BETWEEN :min AND :max ORDER BY amount DESC") + List queryByAmountRange(@Param("min") double min, @Param("max") double max); + + @Query("WHERE amount >= :minAmount") + long countByMinAmount(@Param("minAmount") double minAmount); + + @Query("WHERE status = :status") + boolean existsWithStatus(@Param("status") String status); + + @Query("WHERE customerId IS NOT NULL ORDER BY customerId ASC") + List queryAllWithCustomerId(); + + @Query("WHERE status = :s1 OR status = :s2") + List queryByEitherStatus(@Param("s1") String status1, + @Param("s2") String status2); + + // -- Phase 7: New query derivation operators -- + + List findByTagsContains(String tag); + + List findByTagsNotContains(String tag); + + List findByTagsIsEmpty(); + + List findByTagsIsNotEmpty(); + + List findByTagsSize(int size); + + List findByCustomerIdMatches(String regex); + + List findByStatusIgnoreCase(String status); + + // -- deleteAll() no-arg -- + + void deleteAll(); + + // -- deleteBy* Query Derivation -- + + long deleteByStatus(String status); + + void deleteByAmountLessThan(double maxAmount); + + boolean deleteByCustomerId(String customerId); + + // -- Single-result methods for exception testing -- + + OrderEntity findByCustomerId(String customerId); + + @Find + Optional findOptionalByCustomerId(@By("customerId") String customerId); + + @Query("WHERE customerId = :cid") + OrderEntity queryByCustomerId(@Param("cid") String customerId); + + @Query("WHERE customerId = :cid") + Optional queryOptionalByCustomerId(@Param("cid") String customerId); + + // --- #4 Test Coverage Extension --- + + List findByAmountLessThanEqual(double maxAmount); + + @OrderBy(value = "amount", descending = true) + List findByStatusNot(String status); + + List findByAmountBetween(double min, double max); + + List findByStatusIn(Collection statuses); + + List findByStatusNotIn(Collection statuses); + + List findByCustomerIdStartsWith(String prefix); + + List findByCustomerIdEndsWith(String suffix); + + List findByCustomerIdLike(String pattern); + + List findByCustomerIdIsNull(); + + List findByCustomerIdIsNotNull(); + + List findByUrgentIsTrue(); + + List findByUrgentIsFalse(); + + List findByStatusOrCustomerId(String status, String customerId); + + List findByStatusOrderByAmountAscCustomerIdDesc(String status); + + Stream findByAmountGreaterThanEqualOrderByAmountAsc(double minAmount); + + // --- #6 Stream Support --- + + @Find + @OrderBy("amount") + Stream findStreamByStatus(@By("status") String status); + + @Query("WHERE status = :status ORDER BY amount ASC") + Stream queryStreamByStatus(@Param("status") String status); + + // --- #7 JDQL SELECT with Projection --- + + @Query("SELECT customerId, amount WHERE status = :status ORDER BY amount ASC") + List queryProjectedByStatus(@Param("status") String status); + + @Query("SELECT customerId, amount FROM OrderEntity WHERE status = :status ORDER BY amount ASC") + List queryProjectedWithFrom(@Param("status") String status); + + @Query("SELECT customerId WHERE amount > :minAmount") + Stream queryProjectedStream(@Param("minAmount") double minAmount); + + @Query("SELECT customerId, amount WHERE customerId = :cid") + Optional queryProjectedSingle(@Param("cid") String customerId); + + // --- #8 JDQL Aggregate Functions --- + + @Query("SELECT COUNT(this) WHERE status = :status") + long countByStatusJdql(@Param("status") String status); + + @Query("SELECT SUM(amount) WHERE status = :status") + double sumAmountByStatus(@Param("status") String status); + + @Query("SELECT AVG(amount) WHERE status = :status") + double avgAmountByStatus(@Param("status") String status); + + @Query("SELECT MIN(amount) WHERE status = :status") + double minAmountByStatus(@Param("status") String status); + + @Query("SELECT MAX(amount) WHERE status = :status") + double maxAmountByStatus(@Param("status") String status); + + @Query("SELECT COUNT(this) WHERE amount > :minAmount") + long countByAmountGreaterThan(@Param("minAmount") double minAmount); + + // --- #9 Async (CompletionStage) Support --- + + // Query derivation → async + CompletionStage> findByStatusAsync(String status); + + CompletionStage> findByCustomerIdAsync(String customerId); + + // @Find → async + @Find + @OrderBy("amount") + CompletionStage> findAsyncByStatus(@By("status") String status); + + // @Query JDQL → async + @Query("WHERE status = :status ORDER BY amount ASC") + CompletionStage> queryByStatusAsync(@Param("status") String status); + + @Query("SELECT COUNT(this) WHERE status = :status") + CompletionStage countByStatusAsync(@Param("status") String status); + + // --- #10 JDQL String Literals + NOT Operator --- + + @Query("WHERE status = 'OPEN' ORDER BY amount ASC") + List queryByStringLiteral(); + + @Query("WHERE status = 'OPEN' AND amount > :minAmount ORDER BY amount ASC") + List queryByStringLiteralAndParam(@Param("minAmount") double minAmount); + + @Query("WHERE NOT status = :status ORDER BY amount ASC") + List queryNotByStatus(@Param("status") String status); + + @Query("WHERE NOT status = 'CANCELLED'") + List queryNotCancelled(); + + @Query("WHERE status = :status AND NOT urgent = true ORDER BY amount ASC") + List queryByStatusNotUrgent(@Param("status") String status); + + @Query("WHERE NOT amount > :maxAmount ORDER BY amount ASC") + List queryNotAmountGreaterThan(@Param("maxAmount") double maxAmount); + + @Query("WHERE NOT status IN :statuses ORDER BY amount ASC") + List queryNotInStatuses(@Param("statuses") java.util.Collection statuses); + + @Query("WHERE NOT status LIKE :pattern ORDER BY amount ASC") + List queryNotLike(@Param("pattern") String pattern); + + @Query("SELECT COUNT(this) WHERE status = 'OPEN'") + long countOpenLiteral(); + + // --- Implicit @Param via -parameters compiler option (Jakarta Data §4.6.1) --- + + @Query("WHERE status = :status ORDER BY amount ASC") + List queryByStatusImplicitParam(String status); + + @Query("WHERE status = :status AND amount > :minAmount") + List queryByStatusAndMinAmountImplicit(String status, double minAmount); + + // --- #8v2 JDQL GROUP BY --- + + @Query("SELECT status, COUNT(this) GROUP BY status") + List countGroupByStatus(); + + @Query("SELECT status, COUNT(this), SUM(amount) GROUP BY status") + List statsByStatus(); + + @Query("SELECT status, COUNT(this), SUM(amount) WHERE amount > :min GROUP BY status ORDER BY status ASC") + List statsByStatusFiltered(@Param("min") double minAmount); + + @Query("SELECT status, COUNT(this) GROUP BY status ORDER BY COUNT(this) DESC") + List countGroupByStatusOrderByCount(); + + // --- #8v3 Multi-field GROUP BY --- + + @Query("SELECT status, customerId, COUNT(this) GROUP BY status, customerId") + List countByStatusAndCustomer(); + + @Query("SELECT status, customerId, COUNT(this) GROUP BY status, customerId ORDER BY status ASC, customerId ASC") + List countByStatusAndCustomerSorted(); + + @Query("SELECT status, customerId, COUNT(this) WHERE amount > :minAmount GROUP BY status, customerId ORDER BY COUNT(this) DESC") + List countByStatusAndCustomerFiltered(@Param("minAmount") double minAmount); + + // --- GAP-A2 HAVING --- + + @Query("SELECT status, COUNT(this) GROUP BY status HAVING COUNT(this) > :minCount") + List statusesWithMinCount(@Param("minCount") long minCount); + + @Query("SELECT status, COUNT(this), SUM(amount) GROUP BY status HAVING SUM(amount) >= :minTotal ORDER BY SUM(amount) DESC") + List statusesWithMinTotal(@Param("minTotal") double minTotal); + + @Query("SELECT status, COUNT(this) GROUP BY status HAVING COUNT(this) >= 5") + List statusesWithAtLeast5(); + + @Query("SELECT status, COUNT(this), SUM(amount) GROUP BY status HAVING COUNT(this) > :minCount AND SUM(amount) >= :minTotal") + List statusesWithMultipleHaving(@Param("minCount") long minCount, @Param("minTotal") double minTotal); + + // --- HAVING OR --- + + @Query("SELECT status, COUNT(this), SUM(amount) GROUP BY status HAVING COUNT(this) > :minCount OR SUM(amount) >= :minTotal") + List statusesWithCountOrTotal(@Param("minCount") long minCount, @Param("minTotal") double minTotal); + + // --- GAP-A3: COUNT(field) NULL filtering --- + + @Query("SELECT status, COUNT(customerId) GROUP BY status") + List countNonNullCustomerByStatus(); + + // --- Parenthesized group queries --- + + @Query("WHERE status = :status AND (customerId IS NULL OR customerId = '')") + List queryByStatusWithNullOrEmptyCustomerId(@Param("status") String status); + + @Query("WHERE status = :status AND (amount > :min OR urgent = true) ORDER BY amount ASC") + List queryByStatusWithAmountOrUrgent(@Param("status") String status, @Param("min") double minAmount); + + // --- GAP-A8: Pagination with GROUP BY --- + + @Query("SELECT status, COUNT(this) GROUP BY status ORDER BY status ASC") + Page countGroupByStatusPaged(PageRequest pageRequest); + + // --- Silent data loss fix: @Delete with a single entity-typed parameter must delete the + // given entity via doDelete(entity), not silently match nothing via a bogus {order: } + // condition query. Method name deliberately does NOT start with deleteBy/findBy/countBy/ + // existsBy, since MethodNameParser would otherwise try to parse it as a derived query. --- + + @Delete + void remove(OrderEntity order); +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/PaginatedOrderRepository.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/PaginatedOrderRepository.java new file mode 100644 index 000000000..1bd95eece --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/PaginatedOrderRepository.java @@ -0,0 +1,31 @@ +package de.caluga.morphium.quarkus.it; + +import jakarta.data.Order; +import jakarta.data.page.CursoredPage; +import jakarta.data.page.PageRequest; +import jakarta.data.repository.BasicRepository; +import jakarta.data.repository.By; +import jakarta.data.repository.Find; +import jakarta.data.repository.OrderBy; +import jakarta.data.repository.Param; +import jakarta.data.repository.Query; +import jakarta.data.repository.Repository; + +/** + * Test repository for CursoredPage (keyset pagination). + */ +@Repository +public interface PaginatedOrderRepository extends BasicRepository { + + @Find + @OrderBy("amount") + @OrderBy("id") + CursoredPage findPagedByStatus(@By("status") String status, PageRequest pageRequest); + + @Query("WHERE status = :status") + @OrderBy("amount") + @OrderBy("id") + CursoredPage queryPagedByStatus(@Param("status") String status, PageRequest pageRequest); + + CursoredPage findAll(PageRequest pageRequest, Order sortBy); +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/SlowMigration.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/SlowMigration.java new file mode 100644 index 000000000..a621cf02a --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/SlowMigration.java @@ -0,0 +1,44 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.quarkus.migration.Execution; +import de.caluga.morphium.quarkus.migration.MorphiumChangeUnit; + +/** + * Test migration used by {@code MorphiumMigrationTest}'s lock-renewal regression tests to prove + * that {@code MorphiumMigrationRunner} renews the lock's {@code expires_at} BETWEEN migrations + * (via {@code renewLock()} in the {@code execute()} loop) instead of leaving it to expire + * mid-run. + * + *

    {@link #SLEEP_MS} is mutable (not {@code final}) so the test can temporarily set a short + * sleep well below the in-flight heartbeat's tick interval -- isolating the between-units + * renewal mechanism from the separate in-flight heartbeat, which is covered by its own, + * dedicated test. Callers that override it MUST restore the original value afterwards (e.g. in + * a {@code finally} block) since this is shared, static state. + */ +@MorphiumChangeUnit(id = "900-slow", order = "900", author = "test") +public class SlowMigration { + + /** How long {@link #execute} sleeps, in milliseconds. */ + public static volatile long SLEEP_MS = 1500L; + + @Execution + public void execute(Morphium morphium) throws InterruptedException { + Thread.sleep(SLEEP_MS); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/SlowMigration2.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/SlowMigration2.java new file mode 100644 index 000000000..a4f1c9361 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/SlowMigration2.java @@ -0,0 +1,51 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.quarkus.migration.Execution; +import de.caluga.morphium.quarkus.migration.MorphiumChangeUnit; + +/** + * Second slow test migration used by {@code MorphiumMigrationTest}'s lock-renewal regression + * tests. Depending on the test it is used two different ways: + *

      + *
    • Run directly after {@link SlowMigration} (at a short sleep) to prove that + * {@code renewLock()} renews the lock BETWEEN change units.
    • + *
    • Run alone, with {@link #SLEEP_MS} temporarily raised well above the test's lock TTL, to + * prove that the in-flight heartbeat renews the lock WHILE a single unit is still + * executing.
    • + *
    + * + *

    {@link #SLEEP_MS} is intentionally mutable (not {@code final}) so the second test case can + * raise it for the duration of that one test and restore it afterwards, instead of needing a + * third near-duplicate migration class just to get a different sleep duration. + */ +@MorphiumChangeUnit(id = "901-slow2", order = "901", author = "test") +public class SlowMigration2 { + + /** + * How long {@link #execute} sleeps, in milliseconds. Mutable so tests can temporarily + * override it; callers that do so MUST restore the original value afterwards (e.g. in a + * {@code finally} block) since this is shared, static state. + */ + public static volatile long SLEEP_MS = 1500L; + + @Execution + public void execute(Morphium morphium) throws InterruptedException { + Thread.sleep(SLEEP_MS); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/StatusCount.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/StatusCount.java new file mode 100644 index 000000000..68f3f604a --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/StatusCount.java @@ -0,0 +1,3 @@ +package de.caluga.morphium.quarkus.it; + +public record StatusCount(String status, long count) {} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/StatusCustomerCount.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/StatusCustomerCount.java new file mode 100644 index 000000000..b6e40659c --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/StatusCustomerCount.java @@ -0,0 +1,3 @@ +package de.caluga.morphium.quarkus.it; + +public record StatusCustomerCount(String status, String customerId, long count) {} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/StatusStats.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/StatusStats.java new file mode 100644 index 000000000..ab98b8534 --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/StatusStats.java @@ -0,0 +1,3 @@ +package de.caluga.morphium.quarkus.it; + +public record StatusStats(String status, long count, double totalAmount) {} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/TransactionEventCollector.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/TransactionEventCollector.java new file mode 100644 index 000000000..27c18a02f --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/TransactionEventCollector.java @@ -0,0 +1,55 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.quarkus.transaction.MorphiumTransactionEvent; +import de.caluga.morphium.quarkus.transaction.MorphiumTxPhase; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import static de.caluga.morphium.quarkus.transaction.MorphiumTransactionEvent.Phase.*; + +/** + * Collects {@link MorphiumTransactionEvent}s for test assertions. + */ +@ApplicationScoped +public class TransactionEventCollector { + + private final List events = new CopyOnWriteArrayList<>(); + + void onBeforeCommit(@Observes @MorphiumTxPhase(BEFORE_COMMIT) MorphiumTransactionEvent e) { + events.add(e); + } + + void onAfterCommit(@Observes @MorphiumTxPhase(AFTER_COMMIT) MorphiumTransactionEvent e) { + events.add(e); + } + + void onAfterRollback(@Observes @MorphiumTxPhase(AFTER_ROLLBACK) MorphiumTransactionEvent e) { + events.add(e); + } + + public List getEvents() { + return events; + } + + public void clear() { + events.clear(); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/TransactionalService.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/TransactionalService.java new file mode 100644 index 000000000..79e19ae6a --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/TransactionalService.java @@ -0,0 +1,42 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.quarkus.transaction.MorphiumTransactional; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +/** + * Test service exercising {@link MorphiumTransactional} for integration tests. + */ +@ApplicationScoped +public class TransactionalService { + + @Inject + Morphium morphium; + + @MorphiumTransactional + public void storeSuccessfully(ItemEntity item) { + morphium.store(item); + } + + @MorphiumTransactional + public void storeAndFail(ItemEntity item) { + morphium.store(item); + throw new RuntimeException("forced rollback"); + } +} diff --git a/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/UnversionedEntity.java b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/UnversionedEntity.java new file mode 100644 index 000000000..55c67f50c --- /dev/null +++ b/quarkus-morphium/integration-tests/src/test/java/de/caluga/morphium/quarkus/it/UnversionedEntity.java @@ -0,0 +1,47 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.it; + +import de.caluga.morphium.annotations.Entity; +import de.caluga.morphium.annotations.Id; +import de.caluga.morphium.annotations.Property; + +/** + * Minimal test entity that deliberately has NO {@code @Version} field. + * Used to prove that entities without optimistic-locking support store + * and update normally, without any version tracking/checking. + */ +@Entity(collectionName = "it_unversioned") +public class UnversionedEntity { + + @Id + private String id; + + @Property(fieldName = "name") + private String name; + + @Property(fieldName = "price") + private double price; + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public double getPrice() { return price; } + public void setPrice(double price) { this.price = price; } +} diff --git a/quarkus-morphium/pom.xml b/quarkus-morphium/pom.xml new file mode 100644 index 000000000..cb8d399e3 --- /dev/null +++ b/quarkus-morphium/pom.xml @@ -0,0 +1,78 @@ + + + 4.0.0 + + + de.caluga + morphium-parent + 6.3.2-SNAPSHOT + + + quarkus-morphium-parent + pom + + Quarkus Morphium Extension – Parent + + Quarkus CDI extension that integrates the Morphium MongoDB ORM. + Provides @ApplicationScoped Morphium producer, type-safe @ConfigMapping, + and GraalVM native reflection registration for all @Entity classes. + + + + runtime + deployment + testing + integration-tests + + + + + + + io.quarkus.platform + quarkus-bom + ${quarkus.version} + pom + import + + + org.assertj + assertj-core + 3.27.7 + test + + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${maven.compiler.release} + true + + -Xlint:deprecation,unchecked + + + + + + io.quarkus + quarkus-extension-maven-plugin + ${quarkus.version} + + + + + diff --git a/quarkus-morphium/runtime/pom.xml b/quarkus-morphium/runtime/pom.xml new file mode 100644 index 000000000..268770e04 --- /dev/null +++ b/quarkus-morphium/runtime/pom.xml @@ -0,0 +1,161 @@ + + + 4.0.0 + + + de.caluga + quarkus-morphium-parent + 6.3.2-SNAPSHOT + + + quarkus-morphium + Quarkus Morphium Extension – Runtime + + + + + io.quarkus + quarkus-arc + + + io.quarkus + quarkus-core + + + + io.quarkus + quarkus-smallrye-health + true + + + + io.quarkus + quarkus-jackson + true + + + io.quarkus + quarkus-jsonb + true + + + + jakarta.data + jakarta.data-api + + + + de.caluga + morphium-jakarta-data + ${project.version} + + + + de.caluga + morphium + ${project.version} + + + + ch.qos.logback + logback-classic + + + ch.qos.logback + logback-core + + + + + + io.quarkus + quarkus-tls-registry + + + + io.quarkus + quarkus-devservices + + + + org.junit.jupiter + junit-jupiter + test + + + org.mockito + mockito-core + test + + + org.assertj + assertj-core + test + + + + + + + src/main/resources + false + + + src/main/resources-filtered + true + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + io.quarkus + quarkus-extension-processor + ${quarkus.version} + + + + + + io.quarkus + quarkus-extension-maven-plugin + + + compile + + extension-descriptor + + + ${project.groupId}:${project.artifactId}-deployment:${project.version} + + + + + + + org.apache.maven.plugins + maven-source-plugin + + + org.apache.maven.plugins + maven-javadoc-plugin + + + + diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/CacheConfig.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/CacheConfig.java new file mode 100644 index 000000000..b9aaa3260 --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/CacheConfig.java @@ -0,0 +1,32 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus; + +import io.smallrye.config.WithDefault; + +/** + * Cache configuration group, nested under {@link MorphiumRuntimeConfig#cache()}. + */ +public interface CacheConfig { + + /** Global validity time for cached query results in milliseconds. */ + @WithDefault("60000") + long globalValidTime(); + + /** Whether query-result caching is enabled. */ + @WithDefault("true") + boolean readCacheEnabled(); +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/LocalDateTimeConfig.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/LocalDateTimeConfig.java new file mode 100644 index 000000000..1323f4172 --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/LocalDateTimeConfig.java @@ -0,0 +1,41 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus; + +import io.smallrye.config.WithDefault; + +/** + * Configuration for how {@link java.time.LocalDateTime} values are stored in MongoDB. + */ +public interface LocalDateTimeConfig { + + /** + * Whether to store {@link java.time.LocalDateTime} as a BSON Date ({@code ISODate}) + * instead of the Morphium-native {@code {sec: epochSecond, n: nanos}} Map format. + * + *

    BSON Date format: + *

      + *
    • Is compatible with data written by Morphia (legacy ORM)
    • + *
    • Enables native MongoDB date operations: sort, range queries, {@code $gt/$lt}
    • + *
    • Displays as human-readable ISO dates in mongosh and Atlas UI
    • + *
    + * + *

    Defaults to {@code true}. Set to {@code false} only if you need backward + * compatibility with existing data written by Morphium in the Map format. + */ + @WithDefault("true") + boolean useBsonDate(); +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumBlockingCallDetector.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumBlockingCallDetector.java new file mode 100644 index 000000000..250eab028 --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumBlockingCallDetector.java @@ -0,0 +1,150 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.MorphiumAccessVetoException; +import de.caluga.morphium.MorphiumStorageListener; +import de.caluga.morphium.query.Query; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Detects Morphium write operations that are called from the Vert.x I/O event-loop thread. + * + *

    Morphium write operations are blocking (they communicate synchronously with MongoDB). + * Calling them directly from a Vert.x event-loop thread will stall the event loop, which + * causes health-check timeouts and general request degradation. + * + *

    {@link #registerOn(Morphium)} attaches a {@link MorphiumStorageListener} that logs a + * clear {@code WARN} with fix instructions whenever a write is attempted on an event-loop + * thread. No Vert.x API dependency is required — detection is based solely on the well-known + * thread-name prefix {@code "vert.x-eventloop-thread"}. + * + *

    Fix: annotate the offending JAX-RS method with + * {@code @io.smallrye.common.annotation.RunOnVirtualThread} (preferred) or + * {@code @io.smallrye.common.annotation.Blocking}. + * + *

    Why this is not a CDI bean anymore: this used to be an {@code @ApplicationScoped} + * bean that injected {@code Instance} and dereferenced it (via {@code .get()}) from + * a {@code StartupEvent} observer to register the listener. That dereference is what actually + * triggers {@code MorphiumProducer.buildMorphium()} — a blocking connect with a full retry + * ladder — because {@code Morphium} is a normal-scoped CDI bean whose proxy connects lazily on + * first real use. Running that on a background thread (the previous workaround) only kept the + * Quarkus boot thread free; it did not stop the eager connect attempt itself, so without a + * reachable MongoDB the extension would still burn through the full retry ladder in the + * background — exactly the failure this class must never cause, since it is pure debug + * diagnostics. Registering the listener here, called directly from + * {@link MorphiumProducer#buildMorphium()} right after the real connect has already succeeded, + * makes it structurally impossible for this class to be the cause of a connect: by the time + * {@link #registerOn(Morphium)} runs, the {@code Morphium} instance already exists. + */ +public final class MorphiumBlockingCallDetector { + + private static final Logger log = LoggerFactory.getLogger(MorphiumBlockingCallDetector.class); + private static final String EVENTLOOP_THREAD_PREFIX = "vert.x-eventloop-thread"; + private static final long WARN_INTERVAL_NANOS = Duration.ofSeconds(30).toNanos(); + private static final AtomicLong lastWarnNanos = new AtomicLong(0); + + private MorphiumBlockingCallDetector() {} + + /** + * Registers the storage listener on the given, already-connected {@link Morphium} instance. + * Called from {@link MorphiumProducer#buildMorphium()} once the connection has been + * established — never triggers a connect itself. + */ + public static void registerOn(Morphium morphium) { + morphium.addListener(new MorphiumStorageListener() { + @Override + public void preStore(Morphium m, Object r, boolean isNew) throws MorphiumAccessVetoException { + warnIfOnEventLoop(); + } + + @Override + public void preStore(Morphium m, Map isNew) throws MorphiumAccessVetoException { + warnIfOnEventLoop(); + } + + @Override + public void postStore(Morphium m, Object r, boolean isNew) {} + + @Override + public void postStore(Morphium m, Map isNew) {} + + @Override + public void preRemove(Morphium m, Query q) throws MorphiumAccessVetoException { + warnIfOnEventLoop(); + } + + @Override + public void preRemove(Morphium m, Object r) throws MorphiumAccessVetoException { + warnIfOnEventLoop(); + } + + @Override + public void postRemove(Morphium m, Object r) {} + + @Override + public void postRemove(Morphium m, List lst) {} + + @Override + public void postRemove(Morphium m, Query q) {} + + @Override + public void postLoad(Morphium m, Object o) {} + + @Override + public void postLoad(Morphium m, List o) {} + + @Override + public void preDrop(Morphium m, Class cls) throws MorphiumAccessVetoException {} + + @Override + public void postDrop(Morphium m, Class cls) {} + + @Override + public void preUpdate(Morphium m, Class cls, Enum updateType) throws MorphiumAccessVetoException { + warnIfOnEventLoop(); + } + + @Override + public void postUpdate(Morphium m, Class cls, Enum updateType) {} + }); + } + + private static void warnIfOnEventLoop() { + String threadName = Thread.currentThread().getName(); + if (threadName.startsWith(EVENTLOOP_THREAD_PREFIX) && shouldWarnNow()) { + log.warn(""" + [Morphium] Blocking write operation called from Vert.x I/O thread '{}'. + This blocks the event loop and can cause request timeouts and health-check failures. + Fix: Add @RunOnVirtualThread (recommended) or @Blocking to your JAX-RS method. + See: https://quarkus.io/guides/rest#blocking-non-blocking""", + threadName); + } + } + + private static boolean shouldWarnNow() { + long now = System.nanoTime(); + long last = lastWarnNanos.get(); + return now - last >= WARN_INTERVAL_NANOS && lastWarnNanos.compareAndSet(last, now); + } +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumDevUIJsonRpcService.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumDevUIJsonRpcService.java new file mode 100644 index 000000000..15023e70c --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumDevUIJsonRpcService.java @@ -0,0 +1,91 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus; + +import de.caluga.morphium.Morphium; +import jakarta.inject.Inject; +import jakarta.inject.Singleton; +import org.jboss.logging.Logger; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * JsonRPC service for the Quarkus Dev UI. + * + *

    Provides runtime connection information by querying the actual {@link Morphium} + * instance, including the real replica set status detected via the MongoDB hello handshake. + */ +@Singleton +public class MorphiumDevUIJsonRpcService { + + private static final Logger log = Logger.getLogger(MorphiumDevUIJsonRpcService.class); + + @Inject + Morphium morphium; + + public List> getConnectionInfo() { + List> rows = new ArrayList<>(); + try { + var config = morphium.getConfig(); + var driver = morphium.getDriver(); + + var clusterSettings = config.clusterSettings(); + var hostSeed = clusterSettings.getHostSeed(); + String hosts; + if (hostSeed != null && !hostSeed.isEmpty()) { + hosts = String.join(", ", hostSeed); + } else { + String atlasUrl = clusterSettings.getAtlasUrl(); + hosts = (atlasUrl != null && !atlasUrl.isBlank()) ? sanitizeUri(atlasUrl) : "unknown"; + } + String database = config.connectionSettings().getDatabase(); + boolean isReplicaSet = driver.isReplicaSet(); + String mode = isReplicaSet ? "Replica Set (transactions enabled)" : "Standalone"; + String driverName = driver.getClass().getSimpleName(); + String status = (driver.isConnected()) ? "Connected" : "Disconnected"; + + rows.add(row("Hosts", hosts)); + rows.add(row("Database", database)); + rows.add(row("Mode", mode)); + rows.add(row("Driver", driverName)); + rows.add(row("Status", status)); + } catch (Exception e) { + log.warn("Failed to retrieve Morphium connection info for Dev UI", e); + rows.add(row("Status", "Error retrieving connection info")); + } + return rows; + } + + /** + * Strips userinfo (credentials) from a MongoDB URI to prevent exposing + * passwords in the Dev UI. For example, {@code mongodb+srv://user:pass@host} + * becomes {@code mongodb+srv://***@host}. + */ + private static String sanitizeUri(String uri) { + // Pattern: scheme://userinfo@host... → scheme://***@host... + return uri.replaceFirst("(mongodb(?:\\+srv)?://)([^@]+)@", "$1***@"); + } + + private static Map row(String property, String value) { + Map map = new LinkedHashMap<>(); + map.put("Property", property); + map.put("Value", value); + return map; + } +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumProducer.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumProducer.java new file mode 100644 index 000000000..1f8f75a1a --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumProducer.java @@ -0,0 +1,636 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus; + +import de.caluga.morphium.AnnotationAndReflectionHelper; +import de.caluga.morphium.ClassGraphCache; +import de.caluga.morphium.Morphium; +import de.caluga.morphium.MorphiumConfig; +import de.caluga.morphium.ObjectMapperImpl; +import de.caluga.morphium.annotations.Capped; +import de.caluga.morphium.annotations.Driver; +import de.caluga.morphium.annotations.Embedded; +import de.caluga.morphium.annotations.Entity; +import de.caluga.morphium.annotations.Messaging; +import de.caluga.morphium.config.CollectionCheckSettings; +import de.caluga.morphium.driver.ReadPreference; +import de.caluga.morphium.driver.wire.SslHelper; +import de.caluga.morphium.objectmapping.LocalDateTimeMapper; +import io.quarkus.runtime.ImageMode; +import io.quarkus.tls.TlsConfiguration; +import io.quarkus.tls.TlsConfigurationRegistry; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import javax.net.ssl.SSLContext; +import io.quarkus.runtime.ShutdownEvent; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; +import jakarta.enterprise.inject.Instance; +import jakarta.enterprise.inject.Produces; +import jakarta.inject.Inject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * CDI producer for a single {@link Morphium} instance shared across the application. + * + *

    Design principles: + *

      + *
    • No {@code sun.*} or {@code jdk.internal.*} imports
    • + *
    • No {@link java.lang.reflect.Field#setAccessible} beyond what Morphium itself requires
    • + *
    • Lifecycle managed via CDI {@code @Observes} – no custom shutdown hooks
    • + *
    + */ +@ApplicationScoped +public class MorphiumProducer { + + private static final Logger log = LoggerFactory.getLogger(MorphiumProducer.class); + + @Inject + MorphiumRuntimeConfig config; + + @Inject + Instance tlsRegistryInstance; + + // Kept as a field so the shutdown observer can close it. + private volatile Morphium instance; + + @Produces + @ApplicationScoped + public Morphium morphium() { + if (instance != null) { + return instance; + } + synchronized (this) { + if (instance != null) { + return instance; + } + instance = buildMorphium(); + } + return instance; + } + + void onStop(@Observes ShutdownEvent event) { + if (instance != null) { + log.info("Closing Morphium connection on application shutdown"); + try { + instance.close(); + } catch (Exception e) { + log.warn("Error while closing Morphium", e); + } finally { + instance = null; + } + } + } + + // ------------------------------------------------------------------ + // Internal helpers – no reflection, no Unsafe + // ------------------------------------------------------------------ + + private void configureSsl(MorphiumConfig cfg, SslConfig ssl) { + if (!ssl.enabled()) { + return; + } + + cfg.setUseSSL(true); + cfg.setSslInvalidHostNameAllowed(ssl.invalidHostnameAllowed()); + + // Auth mechanism (e.g. MONGODB-X509) + ssl.authMechanism().ifPresent(cfg::setAuthMechanism); + + // Build SSLContext — explicit keystore/truststore paths take precedence, + // then fall back to the Quarkus TLS registry (quarkus.tls.* properties). + String keystorePath = ssl.keystorePath().orElse(null); + String keystorePassword = ssl.keystorePassword().orElse(null); + String truststorePath = ssl.truststorePath().orElse(null); + String truststorePassword = ssl.truststorePassword().orElse(null); + + if (keystorePath != null || truststorePath != null) { + // Explicit extension-specific paths — existing behavior + try { + SSLContext sslContext = SslHelper.createSslContext( + keystorePath, keystorePassword, + truststorePath, truststorePassword); + cfg.setSslContext(sslContext); + log.debug("SSLContext configured from keystore='{}', truststore='{}'", + keystorePath, truststorePath); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to build SSLContext from quarkus.morphium.ssl configuration: " + e.getMessage(), e); + } + } else { + // No explicit paths — try Quarkus TLS registry (quarkus.tls.* properties) + configureSslFromTlsRegistry(cfg, ssl); + } + + // Explicit X.509 username (subject DN override) + ssl.x509Username().ifPresent(dn -> { + log.debug("Using explicit X.509 username (subject DN): {}", dn); + cfg.authSettings().setMongoLogin(dn); + // No password for X.509 – set empty to avoid SCRAM credential check + cfg.authSettings().setMongoPassword(""); + cfg.authSettings().setMongoAuthDb("$external"); + }); + } + + /** + * Attempts to configure the SSLContext when no explicit keystore/truststore paths + * are set via {@code quarkus.morphium.ssl.*}. + * + *

    Resolution strategy depends on the runtime mode: + *

      + *
    • Explicit TLS name ({@code quarkus.morphium.ssl.tls-configuration-name}): + * Always use the Quarkus TLS registry to look up the named configuration.
    • + *
    • Native mode (no explicit name): Use the Quarkus TLS registry default. + * Native images cannot use {@code javax.net.ssl.*} JVM system properties; + * the native startup script ({@code run-quarkus-native.sh}) writes + * {@code quarkus.tls.key-store.p12.*} properties instead.
    • + *
    • JVM mode (no explicit name): Use {@link SslHelper#createSslContext} + * with null paths, which reads the JVM default SSLContext honoring + * {@code javax.net.ssl.keyStore/trustStore} system properties set by the + * JVM startup script ({@code run-quarkus.sh} KEYSTORE_REGISTER mode).
    • + *
    + */ + private void configureSslFromTlsRegistry(MorphiumConfig cfg, SslConfig ssl) { + // 1. Explicit TLS configuration name — always use the registry + if (ssl.tlsConfigurationName().isPresent()) { + configureSslFromNamedTlsConfig(cfg, ssl.tlsConfigurationName().get()); + return; + } + + // 2. Native mode — use TLS registry default (javax.net.ssl.* not available) + if (ImageMode.current() == ImageMode.NATIVE_RUN) { + configureSslFromDefaultTlsRegistry(cfg); + return; + } + + // 3. JVM mode — do NOT set an explicit SSLContext. + // The Morphium driver will create its own default SSLContext which honors + // javax.net.ssl.keyStore/keyStorePassword/keyStoreType system properties + // set by the base-image startup script (run-quarkus.sh KEYSTORE_REGISTER). + // Setting SslHelper.createSslContext(null,null,null,null) would create an + // EMPTY SSLContext without client certificate, breaking X.509 auth. + log.info("JVM mode: no explicit SSLContext set — driver will use javax.net.ssl.* system properties"); + } + + private void configureSslFromNamedTlsConfig(MorphiumConfig cfg, String name) { + if (!tlsRegistryInstance.isResolvable()) { + throw new IllegalStateException( + "Quarkus TLS registry not available but tls-configuration-name='" + name + "' is set"); + } + TlsConfigurationRegistry tlsRegistry = tlsRegistryInstance.get(); + Optional tlsConfig; + if ("".equals(name)) { + tlsConfig = tlsRegistry.getDefault(); + } else { + tlsConfig = tlsRegistry.get(name); + } + if (tlsConfig.isEmpty()) { + throw new IllegalStateException( + "Quarkus TLS configuration '" + name + "' not found. " + + "Ensure quarkus.tls." + + ("".equals(name) ? "" : name + ".") + + "key-store.* / trust-store.* is configured."); + } + applySslContextFromTlsConfig(cfg, tlsConfig.get()); + } + + private void configureSslFromDefaultTlsRegistry(MorphiumConfig cfg) { + if (!tlsRegistryInstance.isResolvable()) { + log.debug("Quarkus TLS registry not available — no SSLContext configured"); + return; + } + TlsConfigurationRegistry tlsRegistry = tlsRegistryInstance.get(); + Optional tlsConfig = tlsRegistry.getDefault(); + if (tlsConfig.isPresent()) { + applySslContextFromTlsConfig(cfg, tlsConfig.get()); + } else { + log.debug("No default Quarkus TLS configuration found — SSLContext not configured"); + } + } + + private void applySslContextFromTlsConfig(MorphiumConfig cfg, TlsConfiguration tlsConfig) { + try { + SSLContext sslContext = tlsConfig.createSSLContext(); + cfg.setSslContext(sslContext); + String configName = tlsConfig.getName() != null ? tlsConfig.getName() : ""; + log.info("SSLContext configured from Quarkus TLS registry (configuration: '{}')", configName); + } catch (Exception e) { + throw new IllegalStateException( + "Failed to create SSLContext from Quarkus TLS registry: " + e.getMessage(), e); + } + } + + /** + * Parses the {@code quarkus.morphium.read-preference} string into a + * {@link ReadPreference}. Accepted values match {@link MorphiumRuntimeConfig#readPreference()}'s + * documentation: {@code primary}, {@code primaryPreferred}, {@code secondary}, + * {@code secondaryPreferred}, {@code nearest} (case-insensitive). + * + * @param value the configured read preference string + * @return the corresponding {@link ReadPreference}; falls back to {@link ReadPreference#primary()} + * (matching the documented default) for an unrecognized value + */ + static ReadPreference parseReadPreference(String value) { + switch (value.toLowerCase()) { + case "primary": + return ReadPreference.primary(); + case "primarypreferred": + return ReadPreference.primaryPreferred(); + case "secondary": + return ReadPreference.secondary(); + case "secondarypreferred": + return ReadPreference.secondaryPreferred(); + case "nearest": + return ReadPreference.nearest(); + default: + log.warn("Unrecognized quarkus.morphium.read-preference value '{}', falling back to 'primary'", value); + return ReadPreference.primary(); + } + } + + /** + * Validates that {@code quarkus.morphium.username} and {@code quarkus.morphium.password} + * are either both present or both absent. + * + *

    Silently connecting unauthenticated when exactly one of the two is set would be a + * serious, hard-to-notice misconfiguration: the application would appear to work (e.g. + * against a no-auth MongoDB in dev) while every environment where auth is actually required + * would either reject the connection outright, or — worse — silently succeed + * unauthenticated against a MongoDB instance that happens to allow it. + * + * @param usernamePresent {@code config.username().isPresent()} + * @param passwordPresent {@code config.password().isPresent()} + * @throws IllegalStateException if exactly one of the two is present + */ + static void validateCredentialsPresence(boolean usernamePresent, boolean passwordPresent) { + if (usernamePresent != passwordPresent) { + throw new IllegalStateException( + "quarkus.morphium." + (usernamePresent ? "password" : "username") + + " must also be set when quarkus.morphium." + + (usernamePresent ? "username" : "password") + + " is configured -- both or neither, never just one."); + } + } + + /** + * Converts {@code quarkus.morphium.cache.global-valid-time} (a {@code long}, milliseconds) + * to the {@code int} that {@code CacheSettings.setGlobalCacheValidTime(int)} actually takes. + * + *

    A direct {@code (int)} cast silently overflows for any value above + * {@code Integer.MAX_VALUE} ms (~24.8 days) — e.g. a well-intentioned "cache for 30 days" + * config ({@code 2_592_000_000L} ms) would wrap to a negative int and produce a cache that + * never (or immediately) expires, with no warning at all. + * + * @param globalValidTimeMs the configured value, in milliseconds + * @return the same value, safely narrowed to {@code int} + * @throws IllegalStateException if the value exceeds {@code Integer.MAX_VALUE} + */ + static int toIntGlobalCacheValidTime(long globalValidTimeMs) { + if (globalValidTimeMs > Integer.MAX_VALUE) { + throw new IllegalStateException( + "quarkus.morphium.cache.global-valid-time=" + globalValidTimeMs + + " exceeds the maximum supported value of " + Integer.MAX_VALUE + + " ms (~24.8 days) -- morphium-core's CacheSettings.globalCacheValidTime is an int."); + } + return (int) globalValidTimeMs; + } + + /** + * Applies the configured {@link MorphiumRuntimeConfig.IndexCheckMode} to {@code cfg}, + * accounting for the fact that {@code CREATE_ON_WRITE_NEW_COL} — unlike the other three + * modes — sets both {@code IndexCheck} and {@code CappedCheck} + * ({@code MorphiumConfig.setAutoIndexAndCappedCreationOnWrite(true)} sets both to + * {@code CREATE_ON_WRITE_NEW_COL}, see {@code MorphiumConfig} lines ~508-511). + * + *

    That matters because {@code Morphium.initializeAndConnect()} calls + * {@code checkCapped()} unconditionally — with no mode gate at all, unlike + * {@code checkIndices()}, which is only invoked for {@code CREATE_ON_STARTUP}/ + * {@code WARN_ON_STARTUP}. {@code checkCapped()} then calls + * {@code ClassGraphCache.getClassesWithAnnotation(Capped.class.getName())}, which — despite + * the build-time pre-registration this producer performs above — falls through to a live + * ClassGraph classpath scan if that pre-registration is ever missing, cleared, or bypassed. + * A live classpath scan is exactly what native-image cannot do at runtime (no classpath to + * scan), so it crashes. Setting {@code CREATE_ON_WRITE_NEW_COL} alone (Stephan Boesebeck's + * originally proposed fix) only prevents the {@code IndexCheck} scan and misses this + * unconditional {@code CappedCheck} path entirely. + * + *

    Native image: both {@code IndexCheck} and {@code CappedCheck} are + * forced to {@code NO_CHECK}. This deliberately gives up the on-write index/capped-creation + * behaviour of this mode under native-image — accepted here because "starts reliably" beats + * "creates indices automatically", and users who need that behaviour in native mode can + * still call {@code ensureIndicesFor()}/create capped collections explicitly. + * + *

    JVM mode: deliberately left untouched (both checks stay + * {@code CREATE_ON_WRITE_NEW_COL}). On the JVM the {@code checkCapped()} scan is not fatal — + * it costs a one-time startup delay (same live-classpath scan the plain {@code morphium-core} + * library always pays for this mode), and disabling {@code CappedCheck} here would silently + * remove the mode's actual purpose (auto-creating capped collections on first write) for + * every JVM-mode user, not just native-image ones. That regression would be worse than the + * startup cost it avoids. + * + * @param cfg the config being built + * @param indexCheckMode the (already native-image-downgraded, for {@code WARN_ON_STARTUP}) + * configured index check mode + * @param imageMode the current Quarkus {@link ImageMode}, used to decide whether the + * native-image-only downgrade below applies + */ + static void applyIndexCheckMode(MorphiumConfig cfg, MorphiumRuntimeConfig.IndexCheckMode indexCheckMode, + ImageMode imageMode) { + switch (indexCheckMode) { + case CREATE_ON_STARTUP: + // Disable Morphium-internal creation — Producer.ensureIndices() handles it + cfg.collectionCheckSettings().setIndexCheck(CollectionCheckSettings.IndexCheck.NO_CHECK); + break; + case WARN_ON_STARTUP: + cfg.collectionCheckSettings().setIndexCheck(CollectionCheckSettings.IndexCheck.WARN_ON_STARTUP); + break; + case CREATE_ON_WRITE_NEW_COL: + cfg.setAutoIndexAndCappedCreationOnWrite(true); + if (imageMode == ImageMode.NATIVE_RUN) { + // Defence in depth. setAutoIndexAndCappedCreationOnWrite(true) sets BOTH the + // index and the capped check to CREATE_ON_WRITE_NEW_COL (MorphiumConfig + // lines 509-511), and Morphium.initializeAndConnect() calls checkCapped() + // UNCONDITIONALLY -- unlike checkIndices(), which is gated to + // CREATE_ON_STARTUP/WARN_ON_STARTUP -- and checkCapped() would perform a live + // ClassGraph scan, which cannot work in a native image. + // + // In practice buildMorphium() already prevents that scan by pre-registering + // the build-time @Capped list into ClassGraphCache before the Morphium + // constructor runs (an empty list is enough: getClassesWithAnnotation returns + // the pre-registered entry and never reaches the scan). So this is not the + // sole safeguard -- but it is a cheap one that does not depend on that + // ordering staying intact, and it keeps this branch symmetric with the other + // three. Native runs cannot create collections on the fly anyway, so nothing + // of value is disabled here. + cfg.collectionCheckSettings().setIndexCheck(CollectionCheckSettings.IndexCheck.NO_CHECK); + cfg.collectionCheckSettings().setCappedCheck(CollectionCheckSettings.CappedCheck.NO_CHECK); + } + break; + case NO_CHECK: + cfg.collectionCheckSettings().setIndexCheck(CollectionCheckSettings.IndexCheck.NO_CHECK); + break; + } + } + + private Morphium buildMorphium() { + // Clear static caches and pre-register entities for the current ClassLoader. + // This is essential for Quarkus dev-mode hot-reload where the QuarkusClassLoader + // is replaced — without this, stale class references from the previous loader cause + // ObjectMapperImpl/AnnotationAndReflectionHelper to silently skip all @Entity classes. + // In production mode this is a harmless one-time init (clear of empty state + register). + ObjectMapperImpl.clearEntityCache(); + AnnotationAndReflectionHelper.clearTypeIdCache(); + var entityNames = MorphiumRecorder.getMappedClassNames(); + if (!entityNames.isEmpty()) { + AnnotationAndReflectionHelper.registerTypeIds(buildTypeIdMap(entityNames)); + } + + // Pre-populate ClassGraphCache with build-time discovered @Driver, @Messaging, and + // @Capped classes. In GraalVM native mode there is no live classpath, so ClassGraph + // finds nothing; the preRegister() call puts the entries into the cache map before + // Morphium's constructor calls getClassesWithAnnotation(), causing the + // computeIfAbsent to find the pre-populated list and skip the scan entirely. + // Even an empty list is intentional for @Capped — it prevents checkCapped() from + // falling through to a live ClassGraph scan. + var driverNames = MorphiumRecorder.getDriverClassNames(); + if (driverNames.isEmpty()) { + log.warn("Morphium: no @Driver classes were discovered at build time — " + + "the configured driver '{}' may not be found and Morphium may fall back " + + "to SingleMongoConnectDriver", config.driverName()); + } + ClassGraphCache.preRegisterClassesWithAnnotation(Driver.class.getName(), driverNames); + + var messagingNames = MorphiumRecorder.getMessagingClassNames(); + ClassGraphCache.preRegisterClassesWithAnnotation(Messaging.class.getName(), messagingNames); + + var cappedNames = MorphiumRecorder.getCappedClassNames(); + ClassGraphCache.preRegisterClassesWithAnnotation(Capped.class.getName(), cappedNames); + + // Pre-register @Entity and @Embedded classes so ObjectMapperImpl can initialize + // without triggering a live ClassGraph scan (which fails in native mode). + // Unlike @Driver/@Messaging/@Capped (looked up by Morphium's constructor), + // @Entity is looked up by ObjectMapperImpl. and must also be pre-populated. + var entityOnlyNames = MorphiumRecorder.getEntityClassNames(); + ClassGraphCache.preRegisterClassesWithAnnotation(Entity.class.getName(), entityOnlyNames); + + var embeddedOnlyNames = MorphiumRecorder.getEmbeddedClassNames(); + ClassGraphCache.preRegisterClassesWithAnnotation(Embedded.class.getName(), embeddedOnlyNames); + + MorphiumConfig cfg = new MorphiumConfig(); + + cfg.connectionSettings().setDatabase(config.database()); + cfg.driverSettings().setDriverName(config.driverName()); + cfg.connectionSettings().setMaxConnections(config.maxConnections()); + cfg.connectionSettings().setMaxWaitTime(config.maxWaitTime()); + cfg.connectionSettings().setDefaultQueryTimeoutMS(config.defaultQueryTimeoutMs()); + cfg.driverSettings().setDefaultReadPreference(parseReadPreference(config.readPreference())); + + // Morphium's internal checkIndices() uses ClassGraph at startup. + // In Quarkus, we handle index creation explicitly via ensureIndices() using the + // build-time discovered entity list — so always disable Morphium's internal check + // to avoid redundant index creation (Morphium + Producer would both call ensureIndicesFor). + // WARN_ON_STARTUP calls checkIndices() → ClassGraphCache.getClassInfoWithAnnotation() + // which bypasses the preRegister cache and triggers a live ClassGraph scan. + // In native mode that scan crashes because there is no live classpath — so + // WARN_ON_STARTUP must be downgraded to NO_CHECK in native images. + MorphiumRuntimeConfig.IndexCheckMode effectiveIndexCheck = config.indexCheck(); + if (effectiveIndexCheck == MorphiumRuntimeConfig.IndexCheckMode.WARN_ON_STARTUP + && ImageMode.current() == ImageMode.NATIVE_RUN) { + log.warn("Morphium: indexCheck=WARN_ON_STARTUP is not supported in native images " + + "(checkIndices() calls ClassGraph directly, bypassing the preRegister cache). " + + "Downgrading to NO_CHECK for this native run."); + effectiveIndexCheck = MorphiumRuntimeConfig.IndexCheckMode.NO_CHECK; + } + applyIndexCheckMode(cfg, effectiveIndexCheck, ImageMode.current()); + + // Host configuration + if (config.atlasUrl().isPresent()) { + // Use ClusterSettings.setAtlasUrl() for mongodb+srv:// connection strings. + // Morphium resolves the SRV record automatically in initializeAndConnect(). + cfg.clusterSettings().setAtlasUrl(config.atlasUrl().get()); + } else { + for (String host : config.hosts()) { + String trimmed = host.trim(); + if (!trimmed.isEmpty()) { + cfg.clusterSettings().addHostToSeed(trimmed); + } + } + } + + // Replica set name (required for transactions) + if (config.replicaSetName().isPresent()) { + cfg.clusterSettings().setRequiredReplicaSetName(config.replicaSetName().get()); + } + + // Credentials + validateCredentialsPresence(config.username().isPresent(), config.password().isPresent()); + if (config.username().isPresent() && config.password().isPresent()) { + cfg.authSettings().setMongoLogin(config.username().get()); + cfg.authSettings().setMongoPassword(config.password().get()); + cfg.authSettings().setMongoAuthDb(config.authDatabase()); + } + + // Cache settings + cfg.cacheSettings().setGlobalCacheValidTime(toIntGlobalCacheValidTime(config.cache().globalValidTime())); + cfg.cacheSettings().setReadCacheEnabled(config.cache().readCacheEnabled()); + + // TLS / X.509 settings + configureSsl(cfg, config.ssl()); + + log.info("Quarkus Morphium Extension v{} (Morphium {}, Jakarta Data {})", + MorphiumVersion.extensionVersion(), MorphiumVersion.morphiumVersion(), + MorphiumVersion.jakartaDataVersion()); + log.info("Creating Morphium connection to database '{}' (hosts: {}, driver: {}, replicaSetName: {}, ssl: {})", + config.database(), config.hosts(), config.driverName(), + config.replicaSetName().orElse("(none)"), + config.ssl().enabled()); + + Morphium m = connectWithRetry(cfg); + + // Register the blocking-call detector's storage listener only now, after the + // connection has actually been established. Doing this here (instead of e.g. a + // separate CDI StartupEvent observer that injects Instance) makes it + // structurally impossible for the detector to itself be the cause of a connect — + // by the time this line runs, `m` already exists. + MorphiumBlockingCallDetector.registerOn(m); + + // Defensive: ensure the driver knows it's a replica set when a RS name is configured. + // PooledDriver < 6.2.1 only checked host-seed count, missing single-node replica sets. + if (config.replicaSetName().isPresent() && !m.getDriver().isReplicaSet()) { + log.debug("Forcing replicaSet=true on driver (single-node replica set workaround)"); + m.getDriver().setReplicaSet(true); + } + + log.info("Morphium connected (replicaSet: {}, replicaSetName: {})", + m.getDriver().isReplicaSet(), + m.getDriver().getReplicaSetName() != null ? m.getDriver().getReplicaSetName() : "(none)"); + + // Override the default LocalDateTimeMapper with the configured format. + // useBsonDate=true → ISODate (native MongoDB dates, compatible with Morphia data) + // useBsonDate=false → Map{sec, n} (legacy Morphium format) + m.getMapper().registerCustomMapperFor(LocalDateTime.class, + new LocalDateTimeMapper(config.localDateTime().useBsonDate())); + + // Morphium's built-in index creation uses ClassGraph which does not work + // with Quarkus's classloader. Use the entity classes discovered at build time + // and explicitly ensure their indexes — but only when configured to do so. + if (config.indexCheck() == MorphiumRuntimeConfig.IndexCheckMode.CREATE_ON_STARTUP) { + ensureIndices(m); + } + + return m; + } + + /** + * Creates a Morphium instance with retry logic. In containerized CI environments + * (e.g. Docker-in-Docker), the MongoDB replica set primary may not be immediately + * reachable after the container reports ready. This method retries the connection + * with linear backoff (2s, 4s, 6s, ...) to handle transient startup delays. + */ + private Morphium connectWithRetry(MorphiumConfig cfg) { + int maxAttempts = Math.max(1, config.connectRetries()); + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return new Morphium(cfg); + } catch (Exception e) { + boolean isTransient = isTransientConnectionError(e); + if (!isTransient || attempt == maxAttempts) { + throw e; + } + long delayMs = attempt * 2000L; + log.warn("Morphium connection attempt {}/{} failed: {}. Retrying in {}ms...", + attempt, maxAttempts, e.getMessage(), delayMs); + try { + Thread.sleep(delayMs); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while retrying Morphium connection", ie); + } + } + } + throw new IllegalStateException("Unreachable"); + } + + private static boolean isTransientConnectionError(Throwable t) { + while (t != null) { + String msg = t.getMessage(); + if (msg != null && (msg.contains("No primary node found") + || msg.contains("not connected yet"))) { + return true; + } + t = t.getCause(); + } + return false; + } + + /** + * Builds a typeId→FQCN map from the entity class names discovered at build time. + * Loads each class, reads its @Entity/@Embedded annotation, and extracts the typeId. + */ + private Map buildTypeIdMap(List classNames) { + Map typeIds = new HashMap<>(); + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + for (String cn : classNames) { + try { + Class cls = Class.forName(cn, false, cl); + Entity entity = cls.getAnnotation(Entity.class); + if (entity != null) { + if (!".".equals(entity.typeId())) { + typeIds.put(entity.typeId(), cn); + } + typeIds.put(cn, cn); + } + Embedded embedded = cls.getAnnotation(Embedded.class); + if (embedded != null) { + if (!".".equals(embedded.typeId())) { + typeIds.put(embedded.typeId(), cn); + } + typeIds.put(cn, cn); + } + } catch (ClassNotFoundException e) { + log.warn("Could not load entity class for type ID registration: {}", cn); + } + } + return typeIds; + } + + /** + * Ensures MongoDB indexes for all {@code @Entity} classes discovered at build time. + * + *

    Important: This must iterate only {@code @Entity} classes, not the combined + * {@code @Entity}+{@code @Embedded} list from {@link MorphiumRecorder#getMappedClassNames()}. + * {@code Morphium.ensureIndicesFor()} calls {@code ObjectMapperImpl.getCollectionName()}, + * which throws {@code IllegalArgumentException} for {@code @Embedded}-only classes + * (they have no collection name). Using {@link MorphiumRecorder#getEntityClassNames()} avoids this. + */ + private void ensureIndices(Morphium m) { + for (String className : MorphiumRecorder.getEntityClassNames()) { + try { + Class entityClass = Thread.currentThread().getContextClassLoader().loadClass(className); + m.ensureIndicesFor(entityClass); + log.debug("Ensured indexes for {}", className); + } catch (ClassNotFoundException e) { + log.warn("Could not load entity class for index creation: {}", className); + } catch (Exception e) { + log.warn("Failed to ensure indexes for entity class {}: {}", className, e.getMessage(), e); + } + } + } +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumRecorder.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumRecorder.java new file mode 100644 index 000000000..d5b4076d5 --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumRecorder.java @@ -0,0 +1,157 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.quarkus.migration.MorphiumMigrationRunner; +import io.quarkus.arc.Arc; +import io.quarkus.arc.InstanceHandle; +import io.quarkus.runtime.annotations.Recorder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; +import java.util.List; + +/** + * Quarkus {@link Recorder} for the Morphium extension. + * + *

    Stores the list of {@code @Entity} and {@code @Embedded} class names + * discovered at build time so that {@link MorphiumProducer} can clear caches + * and pre-register them via {@code AnnotationAndReflectionHelper.registerTypeIds()} + * when the {@code Morphium} instance is created. This skips the ClassGraph scan + * at runtime and handles dev-mode hot-reload. + * + *

    Also stores {@code @MorphiumChangeUnit} class names and triggers migration + * execution at runtime when {@code quarkus.morphium.migration.migrate-at-start=true}. + */ +@Recorder +public class MorphiumRecorder { + + private static final Logger log = LoggerFactory.getLogger(MorphiumRecorder.class); + + private static volatile List mappedClassNames = Collections.emptyList(); + private static volatile List migrationClassNames = Collections.emptyList(); + private static volatile List driverClassNames = Collections.emptyList(); + private static volatile List messagingClassNames = Collections.emptyList(); + private static volatile List cappedClassNames = Collections.emptyList(); + private static volatile List entityClassNames = Collections.emptyList(); + private static volatile List embeddedClassNames = Collections.emptyList(); + + public void setMappedClassNames(List classNames) { + mappedClassNames = classNames == null ? Collections.emptyList() : List.copyOf(classNames); + } + + public void setDriverClassNames(List classNames) { + driverClassNames = classNames == null ? Collections.emptyList() : List.copyOf(classNames); + if (!driverClassNames.isEmpty()) { + log.debug("Registered {} @Driver classes for native-image pre-population", driverClassNames.size()); + } + } + + public void setMessagingClassNames(List classNames) { + messagingClassNames = classNames == null ? Collections.emptyList() : List.copyOf(classNames); + if (!messagingClassNames.isEmpty()) { + log.debug("Registered {} @Messaging classes for native-image pre-population", messagingClassNames.size()); + } + } + + public void setCappedClassNames(List classNames) { + cappedClassNames = classNames == null ? Collections.emptyList() : List.copyOf(classNames); + log.debug("Registered {} @Capped classes for native-image pre-population (empty list prevents ClassGraph scan)", cappedClassNames.size()); + } + + public void setEntityClassNames(List classNames) { + entityClassNames = classNames == null ? Collections.emptyList() : List.copyOf(classNames); + log.debug("Registered {} @Entity classes for native-image ClassGraphCache pre-population", entityClassNames.size()); + } + + public void setEmbeddedClassNames(List classNames) { + embeddedClassNames = classNames == null ? Collections.emptyList() : List.copyOf(classNames); + log.debug("Registered {} @Embedded classes for native-image ClassGraphCache pre-population", embeddedClassNames.size()); + } + + public void setMigrationClassNames(List classNames) { + migrationClassNames = classNames == null ? Collections.emptyList() : List.copyOf(classNames); + if (!migrationClassNames.isEmpty()) { + log.debug("Registered {} @MorphiumChangeUnit migration classes", migrationClassNames.size()); + } + } + + /** + * Called at RUNTIME_INIT after the BeanContainer is available. + * Triggers migration execution if configured. + */ + public void runMigrations() { + // Resolve config first to check migrateAtStart before triggering Morphium initialization + try (InstanceHandle configHandle = Arc.container().instance(MorphiumRuntimeConfig.class)) { + MorphiumRuntimeConfig config = configHandle.get(); + if (config == null) { + throw new IllegalStateException("MorphiumRuntimeConfig not available — cannot run migrations"); + } + + if (!config.migration().migrateAtStart()) { + log.debug("quarkus.morphium.migration.migrate-at-start=false — skipping migrations"); + return; + } + + if (migrationClassNames.isEmpty()) { + log.info("No @MorphiumChangeUnit classes discovered — nothing to migrate"); + return; + } + + // Only resolve Morphium (triggering DB connection) when migrations are actually needed + try (InstanceHandle morphiumHandle = Arc.container().instance(Morphium.class)) { + Morphium morphium = morphiumHandle.get(); + if (morphium == null) { + throw new IllegalStateException("Morphium bean not available — cannot run migrations"); + } + + log.info("Running {} database migration(s) at startup", migrationClassNames.size()); + MorphiumMigrationRunner runner = new MorphiumMigrationRunner(morphium, config.migration()); + runner.execute(migrationClassNames); + } + } + } + + static List getMappedClassNames() { + return mappedClassNames; + } + + static List getMigrationClassNames() { + return migrationClassNames; + } + + static List getDriverClassNames() { + return driverClassNames; + } + + static List getMessagingClassNames() { + return messagingClassNames; + } + + static List getCappedClassNames() { + return cappedClassNames; + } + + static List getEntityClassNames() { + return entityClassNames; + } + + static List getEmbeddedClassNames() { + return embeddedClassNames; + } +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumRuntimeConfig.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumRuntimeConfig.java new file mode 100644 index 000000000..942f9530e --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumRuntimeConfig.java @@ -0,0 +1,175 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus; + +import io.quarkus.runtime.annotations.ConfigPhase; +import io.quarkus.runtime.annotations.ConfigRoot; +import io.smallrye.config.ConfigMapping; +import io.smallrye.config.WithDefault; + +import de.caluga.morphium.quarkus.migration.MorphiumMigrationConfig; + +import java.util.List; +import java.util.Optional; + +/** + * Type-safe runtime configuration for the Morphium extension. + * + *

    All properties are resolved from {@code application.properties} at + * startup – no reflection, no Unsafe access, purely CDI/SmallRye Config. + * + *

    Example {@code application.properties}: + *

    {@code
    + * quarkus.morphium.database=my-app-db
    + * quarkus.morphium.hosts=mongo1:27017,mongo2:27017
    + * quarkus.morphium.username=admin
    + * quarkus.morphium.password=changeit
    + * quarkus.morphium.max-connections=250
    + * }
    + */ +@ConfigMapping(prefix = "quarkus.morphium") +@ConfigRoot(phase = ConfigPhase.RUN_TIME) +public interface MorphiumRuntimeConfig { + + /** + * MongoDB host list in {@code host:port} format. + * Multiple hosts are separated by commas in application.properties. + */ + @WithDefault("localhost:27017") + List hosts(); + + /** MongoDB database name. */ + String database(); + + /** MongoDB username (optional). */ + Optional username(); + + /** MongoDB password (optional). */ + Optional password(); + + /** Authentication database, defaults to {@code admin}. */ + @WithDefault("admin") + String authDatabase(); + + /** + * Read preference for MongoDB queries. + * Accepted values: {@code primary}, {@code primaryPreferred}, + * {@code secondary}, {@code secondaryPreferred}, {@code nearest}. + */ + @WithDefault("primary") + String readPreference(); + + /** + * Index creation strategy. Controls when and if Morphium ensures that + * {@code @Index} annotations are reflected as actual MongoDB indexes. + * + *
      + *
    • {@code create-on-startup} – (default) create missing indexes + * when the Morphium instance connects. Reliable even when the first + * write happens inside a transaction.
    • + *
    • {@code warn-on-startup} – log a warning for every missing index at + * startup but do not create them.
    • + *
    • {@code create-on-write-new-col} – create indexes lazily, only when + * writing to a collection that does not yet exist (skipped inside + * transactions).
    • + *
    • {@code no-check} – disable all index management.
    • + *
    + */ + @WithDefault("create-on-startup") + IndexCheckMode indexCheck(); + + /** Strategy for automatic index management. */ + enum IndexCheckMode { + /** Do not check or create indexes. */ + NO_CHECK, + /** Log warnings for missing indexes at startup. */ + WARN_ON_STARTUP, + /** Create missing indexes at startup (recommended). */ + CREATE_ON_STARTUP, + /** Create indexes only when writing to a new collection (not inside transactions). */ + CREATE_ON_WRITE_NEW_COL + } + + /** Maximum number of MongoDB connections in the pool. */ + @WithDefault("250") + int maxConnections(); + + /** + * Maximum time (in milliseconds) for low-level operations such as waiting + * for a connection from the pool, driver-level timeouts, and change streams. + * + *

    This does not affect query execution time limits — use + * {@link #defaultQueryTimeoutMs()} for that. + */ + @WithDefault("2000") + int maxWaitTime(); + + /** + * Default server-side time limit (in milliseconds) for queries when no + * per-query {@code maxTimeMS} is set via {@code Query.setMaxTimeMS()}. + * + *

    MongoDB enforces this as {@code maxTimeMS} across the entire cursor + * lifecycle (initial {@code find} + all subsequent {@code getMore} operations). + * If a query exceeds this limit, MongoDB returns error 50 ({@code ExceededTimeLimit}). + * + *

    Set to {@code 0} (the default) to disable the server-side time limit + * entirely (Morphium will set {@code noCursorTimeout} instead). Set to a + * positive value (e.g. {@code 60000}) to enforce a global query timeout. + */ + @WithDefault("0") + int defaultQueryTimeoutMs(); + + /** + * Optional MongoDB Atlas connection string ({@code mongodb+srv://...}). + * When present this overrides {@link #hosts()}. + */ + Optional atlasUrl(); + + /** + * Morphium driver name. Defaults to {@code PooledDriver}. + * Use {@code InMemDriver} for tests (no MongoDB required). + */ + @WithDefault("PooledDriver") + String driverName(); + + /** + * MongoDB replica set name. When set, Morphium connects in replica set mode + * which is required for transactions. Dev Services sets this automatically + * when {@code quarkus.morphium.devservices.replica-set=true}. + */ + Optional replicaSetName(); + + /** + * Number of connection attempts before giving up (minimum {@code 1}). + * Useful in CI environments (Docker-in-Docker) where the MongoDB replica set + * primary may not be immediately reachable after the container starts. + * Set to {@code 1} to disable retries. Values below 1 are treated as 1. + */ + @WithDefault("5") + int connectRetries(); + + /** Nested cache configuration. */ + CacheConfig cache(); + + /** Nested TLS / X.509 configuration. */ + SslConfig ssl(); + + /** Nested LocalDateTime serialization configuration. */ + LocalDateTimeConfig localDateTime(); + + /** Nested database migration configuration. */ + MorphiumMigrationConfig migration(); +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumVersion.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumVersion.java new file mode 100644 index 000000000..70fca3303 --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/MorphiumVersion.java @@ -0,0 +1,64 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus; + +import java.io.InputStream; +import java.util.Properties; + +/** + * Provides the quarkus-morphium extension, Morphium core, and Jakarta Data versions. + * Values are read from {@code META-INF/morphium-version.properties} which is + * populated by Maven resource filtering at build time. + */ +public final class MorphiumVersion { + + private static final String UNKNOWN = "unknown"; + private static final String EXTENSION_VERSION; + private static final String MORPHIUM_VERSION; + private static final String JAKARTA_DATA_VERSION; + + static { + Properties props = new Properties(); + try (InputStream is = MorphiumVersion.class.getClassLoader() + .getResourceAsStream("META-INF/morphium-version.properties")) { + if (is != null) { + props.load(is); + } + } catch (Exception ignored) { + // fall through — versions stay "unknown" + } + EXTENSION_VERSION = props.getProperty("extension.version", UNKNOWN); + MORPHIUM_VERSION = props.getProperty("morphium.version", UNKNOWN); + JAKARTA_DATA_VERSION = props.getProperty("jakarta.data.version", UNKNOWN); + } + + private MorphiumVersion() {} + + /** Returns the quarkus-morphium extension version (e.g. {@code "1.0.1-SNAPSHOT"}). */ + public static String extensionVersion() { + return EXTENSION_VERSION; + } + + /** Returns the Morphium core library version (e.g. {@code "6.2.1"}). */ + public static String morphiumVersion() { + return MORPHIUM_VERSION; + } + + /** Returns the Jakarta Data API version (e.g. {@code "1.0.0"}). */ + public static String jakartaDataVersion() { + return JAKARTA_DATA_VERSION; + } +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/SslConfig.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/SslConfig.java new file mode 100644 index 000000000..4298c7b86 --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/SslConfig.java @@ -0,0 +1,109 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus; + +import io.smallrye.config.WithDefault; + +import java.util.Optional; + +/** + * TLS / X.509 configuration group, nested under {@link MorphiumRuntimeConfig#ssl()}. + * + *

    TLS-only (encrypted transport, server certificate validation):

    + *
    {@code
    + * quarkus.morphium.ssl.enabled=true
    + * quarkus.morphium.ssl.truststore-path=/etc/certs/mongo-truststore.jks
    + * quarkus.morphium.ssl.truststore-password=changeit
    + * }
    + * + *

    X.509 client-certificate authentication (MongoDB Atlas):

    + *
    {@code
    + * quarkus.morphium.ssl.enabled=true
    + * quarkus.morphium.ssl.auth-mechanism=MONGODB-X509
    + * quarkus.morphium.ssl.keystore-path=/etc/certs/client-keystore.p12
    + * quarkus.morphium.ssl.keystore-password=secret
    + * quarkus.morphium.ssl.truststore-path=/etc/certs/mongo-truststore.jks
    + * quarkus.morphium.ssl.truststore-password=changeit
    + * # Optional – overrides the subject DN extracted from the certificate:
    + * # quarkus.morphium.ssl.x509-username=CN=myUser,O=myOrg,C=DE
    + * }
    + */ +public interface SslConfig { + + /** Whether TLS is enabled for the MongoDB connection. Default: {@code false}. */ + @WithDefault("false") + boolean enabled(); + + /** + * Authentication mechanism. + *
      + *
    • Absent / {@code SCRAM-SHA-256} – standard username/password auth (default).
    • + *
    • {@code MONGODB-X509} – X.509 client-certificate authentication. + * Requires {@link #enabled() ssl.enabled=true} and a keystore ({@link #keystorePath()}) + * containing the client certificate.
    • + *
    + */ + Optional authMechanism(); + + /** + * Path to the keystore file (JKS or PKCS12) containing the client certificate + * for X.509 authentication. Also used for mutual TLS. + */ + Optional keystorePath(); + + /** Password for the keystore. */ + Optional keystorePassword(); + + /** + * Path to the truststore file used to validate the MongoDB server certificate. + * When absent the JVM default truststore is used. + */ + Optional truststorePath(); + + /** Password for the truststore. */ + Optional truststorePassword(); + + /** + * Allow invalid / self-signed hostnames in the server certificate. + * Do not set in production. Default: {@code false}. + */ + @WithDefault("false") + boolean invalidHostnameAllowed(); + + /** + * Explicit X.509 subject DN to use as the MongoDB username. + * When absent the subject DN is extracted automatically from the client certificate + * presented during the TLS handshake. + * Example: {@code CN=myUser,OU=myUnit,O=myOrg,C=DE} + */ + Optional x509Username(); + + /** + * Name of a Quarkus TLS configuration (from {@code quarkus.tls..*}) to use + * for the MongoDB connection. The SSLContext is obtained from the Quarkus TLS registry + * instead of from explicit keystore/truststore paths. + * + *

    Use the special value {@code } to explicitly select the unnamed default + * TLS configuration. + * + *

    When absent and no explicit {@link #keystorePath()} / {@link #truststorePath()} + * is configured, the extension automatically falls back to the default (unnamed) Quarkus + * TLS configuration if one is available. This is the recommended setup for native images + * where the runtime script writes {@code quarkus.tls.key-store.p12.*} / + * {@code quarkus.tls.trust-store.p12.*} properties. + */ + Optional tlsConfigurationName(); +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/data/QuarkusMorphiumRepository.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/data/QuarkusMorphiumRepository.java new file mode 100644 index 000000000..e348212c3 --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/data/QuarkusMorphiumRepository.java @@ -0,0 +1,38 @@ +package de.caluga.morphium.quarkus.data; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.data.AbstractMorphiumRepository; +import de.caluga.morphium.data.RepositoryMetadata; +import jakarta.annotation.PostConstruct; +import jakarta.inject.Inject; + +/** + * Quarkus-specific subclass of {@link AbstractMorphiumRepository} that injects + * the {@link Morphium} instance via CDI {@code @Inject}. + *

    + * Gizmo-generated repository implementations extend this class instead of + * {@link AbstractMorphiumRepository} directly, so that the Morphium instance + * is automatically injected by the Quarkus CDI container. + * + * @param the entity type + * @param the primary-key type + */ +public abstract class QuarkusMorphiumRepository extends AbstractMorphiumRepository { + + @Inject + Morphium morphium; + + protected QuarkusMorphiumRepository(RepositoryMetadata metadata) { + super(metadata); + } + + @PostConstruct + void init() { + setMorphium(morphium); + } + + @Override + public Morphium getMorphium() { + return morphium; + } +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/health/MorphiumLivenessCheck.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/health/MorphiumLivenessCheck.java new file mode 100644 index 000000000..a897384bf --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/health/MorphiumLivenessCheck.java @@ -0,0 +1,59 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.health; + +import de.caluga.morphium.Morphium; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.health.HealthCheck; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.eclipse.microprofile.health.HealthCheckResponseBuilder; +import org.eclipse.microprofile.health.Liveness; + +/** + * Liveness health check for Morphium. + * + *

    Reports DOWN only if the {@link Morphium} bean itself is unusable (e.g. a + * misconfiguration prevents even constructing the driver). Does not report DOWN on a + * lost MongoDB connection. + * + *

    Rationale: liveness answers "is this process alive and should Kubernetes restart it if + * not", not "is a downstream dependency reachable". Restarting the pod does not fix an + * unreachable MongoDB server — it just adds a restart storm on top of the outage, restarting + * every replica in the deployment simultaneously and taking the application fully offline until + * MongoDB itself recovers. DB connectivity belongs in the readiness probe instead + * ({@link MorphiumReadinessCheck}), which correctly takes the pod out of the Service's endpoint + * list without killing it, and automatically re-adds it once the connection recovers. + */ +@Liveness +@ApplicationScoped +public class MorphiumLivenessCheck implements HealthCheck { + + @Inject + Morphium morphium; + + @Override + public HealthCheckResponse call() { + HealthCheckResponseBuilder builder = HealthCheckResponse.named("Morphium liveness check"); + try { + builder.withData("database", morphium.getConfig().connectionSettings().getDatabase()) + .withData("driver", morphium.getDriver().getClass().getSimpleName()); + return builder.up().build(); + } catch (Exception e) { + return builder.down().withData("error", e.getMessage()).build(); + } + } +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/health/MorphiumReadinessCheck.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/health/MorphiumReadinessCheck.java new file mode 100644 index 000000000..02456691e --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/health/MorphiumReadinessCheck.java @@ -0,0 +1,94 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.health; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.driver.MorphiumDriver; +import de.caluga.morphium.driver.MorphiumDriver.DriverStatsKey; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.health.HealthCheck; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.eclipse.microprofile.health.HealthCheckResponseBuilder; +import org.eclipse.microprofile.health.Readiness; + +import java.util.Map; + +/** + * Readiness health check for Morphium. + * + *

    Reports DOWN only when the driver is no longer connected. Pool statistics + * (connections in use, threads waiting, etc.) are included as informational + * metadata but do not affect the UP/DOWN status. + * + *

    Rationale: transient pool saturation during bulk operations is normal and + * should not cause Kubernetes to remove the pod from service. Pool utilization + * belongs in metrics/monitoring (e.g. Prometheus), not in readiness probes. + * This is consistent with how other Quarkus extensions handle readiness + * (e.g. the MongoDB client extension only pings the server). + */ +@Readiness +@ApplicationScoped +public class MorphiumReadinessCheck implements HealthCheck { + + @Inject + Morphium morphium; + + @Override + public HealthCheckResponse call() { + HealthCheckResponseBuilder builder = HealthCheckResponse.named("Morphium readiness check"); + try { + MorphiumDriver driver = morphium.getDriver(); + boolean connected = driver.isConnected(); + + builder.withData("database", morphium.getConfig().connectionSettings().getDatabase()) + .status(connected); + + // Pool stats are best-effort informational metadata. + // During heavy load (e.g. bulk imports), stat collection may fail -- + // this must never affect the UP/DOWN status. + try { + Map stats = driver.getDriverStats(); + long borrowed = toLong(stats, DriverStatsKey.CONNECTIONS_BORROWED); + long released = toLong(stats, DriverStatsKey.CONNECTIONS_RELEASED); + builder.withData("connectionsInUse", toLong(stats, DriverStatsKey.CONNECTIONS_IN_USE)) + .withData("connectionsInPool", toLong(stats, DriverStatsKey.CONNECTIONS_IN_POOL)) + .withData("connectionsBorrowed", borrowed) + .withData("connectionsReleased", released) + .withData("connectionsBorrowedMinusReleased", borrowed - released) + .withData("threadsWaiting", toLong(stats, DriverStatsKey.THREADS_WAITING_FOR_CONNECTION)) + .withData("errors", toLong(stats, DriverStatsKey.ERRORS)); + + Map hostConnections = driver.getNumConnectionsByHost(); + if (hostConnections != null && !hostConnections.isEmpty()) { + for (Map.Entry entry : hostConnections.entrySet()) { + builder.withData("host:" + entry.getKey(), entry.getValue()); + } + } + } catch (Exception statsEx) { + builder.withData("statsUnavailable", statsEx.getMessage()); + } + + return builder.build(); + } catch (Exception e) { + return builder.down().withData("error", e.getMessage()).build(); + } + } + + private static long toLong(Map stats, DriverStatsKey key) { + return stats.getOrDefault(key, 0.0).longValue(); + } +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/health/MorphiumStartupCheck.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/health/MorphiumStartupCheck.java new file mode 100644 index 000000000..3813a96ff --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/health/MorphiumStartupCheck.java @@ -0,0 +1,84 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.health; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.driver.MorphiumDriver; +import de.caluga.morphium.driver.MorphiumDriver.DriverStatsKey; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import org.eclipse.microprofile.health.HealthCheck; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.eclipse.microprofile.health.HealthCheckResponseBuilder; +import org.eclipse.microprofile.health.Startup; + +import java.util.Map; + +/** + * Startup health check for Morphium. + * + *

    Reports DOWN until the initial connection has been established. + * A DOWN startup probe causes Kubernetes to defer liveness and readiness probes. + */ +@Startup +@ApplicationScoped +public class MorphiumStartupCheck implements HealthCheck { + + @Inject + Morphium morphium; + + @Override + public HealthCheckResponse call() { + HealthCheckResponseBuilder builder = HealthCheckResponse.named("Morphium startup check"); + try { + MorphiumDriver driver = morphium.getDriver(); + + Map stats = driver.getDriverStats(); + double opened = stats.getOrDefault(DriverStatsKey.CONNECTIONS_OPENED, 0.0); + + builder.withData("database", morphium.getConfig().connectionSettings().getDatabase()) + .withData("connectionsOpened", (long) opened); + + boolean everConnected = isEverConnected(opened, driver.isConnected()); + return builder.status(everConnected).build(); + } catch (Exception e) { + return builder.down().withData("error", e.getMessage()).build(); + } + } + + /** + * The SRV-discovery-tolerant "ever connected" latch. {@code PooledDriver.isConnected()} + * iterates over the hosts map, which may still be empty during SRV discovery. + * {@code connectionsOpened} is a monotonically increasing counter that proves at least one + * TCP connection was successfully established, regardless of host-map state. This latch is + * intentionally one-way: once {@code true}, the startup probe never goes back to + * {@code false} again — transient disconnects are the liveness probe's concern, not this + * one's. + * + *

    Package-private (not {@code private}) specifically so + * {@code MorphiumStartupCheckTest} can exercise the exact production formula directly, + * rather than a duplicated copy that could silently drift out of sync with this method + * (e.g. a future edit flipping {@code ||} to {@code &&} here would go undetected by a test + * asserting against its own separate copy of the same expression). + * + * @param connectionsOpened the driver's {@code CONNECTIONS_OPENED} stat + * @param driverConnected {@code driver.isConnected()} + * @return {@code true} once either signal has ever indicated a successful connection + */ + static boolean isEverConnected(double connectionsOpened, boolean driverConnected) { + return connectionsOpened > 0 || driverConnected; + } +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/json/MorphiumIdJacksonModule.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/json/MorphiumIdJacksonModule.java new file mode 100644 index 000000000..7bc47dcc6 --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/json/MorphiumIdJacksonModule.java @@ -0,0 +1,86 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.json; + +import java.io.IOException; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; +import com.fasterxml.jackson.databind.module.SimpleModule; +import com.fasterxml.jackson.databind.ser.std.StdSerializer; + +import de.caluga.morphium.driver.MorphiumId; + +import io.quarkus.jackson.ObjectMapperCustomizer; + +import jakarta.inject.Singleton; + +/** + * Registers a Jackson {@link com.fasterxml.jackson.databind.Module Module} that + * (de)serializes {@link MorphiumId} as its canonical 24-character hex string. + * + *

    Why this exists. Without a custom serializer Jackson walks the + * getters of {@code MorphiumId} ({@code getPid()}, {@code getCounter()}, + * {@code getMachineId()}, {@code getBytes()}, {@code getTime()}) and emits the + * internal struct: + *

    {@code {"pid":..,"counter":..,"machineId":..,"bytes":"..","time":..}}
    + * Frontend grids that key rows by id call {@code String(row.id)} on that object + * and get the literal {@code "[object Object]"} — every row collapses to the + * same key, row identity is lost, and the grid re-renders every cell on each + * change-detection tick (flicker, lost focus, runaway memory, renderer crash). + * The hex string is the only usable wire form of an id. + * + *

    The deserializer mirrors the serializer so REST endpoints accepting a + * {@code MorphiumId} as a path/query/body parameter parse the hex string back + * into a real {@code MorphiumId}. + * + *

    This bean is registered automatically by the extension's build-time + * processor when {@code quarkus-jackson} is on the classpath; consumers do not + * need to declare it. Jackson is an optional dependency of the + * extension, so this class is only loaded when a Jackson-based JSON layer + * (e.g. {@code quarkus-rest-jackson}) is actually present. + */ +@Singleton +public class MorphiumIdJacksonModule implements ObjectMapperCustomizer { + + @Override + public void customize(ObjectMapper mapper) { + SimpleModule module = new SimpleModule("MorphiumIdModule"); + + module.addSerializer(MorphiumId.class, new StdSerializer<>(MorphiumId.class) { + @Override + public void serialize(MorphiumId value, JsonGenerator gen, SerializerProvider provider) + throws IOException { + gen.writeString(value.toString()); + } + }); + + module.addDeserializer(MorphiumId.class, new StdDeserializer<>(MorphiumId.class) { + @Override + public MorphiumId deserialize(JsonParser parser, DeserializationContext ctx) + throws IOException { + String hex = parser.getValueAsString(); + return hex == null || hex.isBlank() ? null : new MorphiumId(hex); + } + }); + + mapper.registerModule(module); + } +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/json/MorphiumIdJsonbAdapter.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/json/MorphiumIdJsonbAdapter.java new file mode 100644 index 000000000..f73d6c44b --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/json/MorphiumIdJsonbAdapter.java @@ -0,0 +1,42 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.json; + +import de.caluga.morphium.driver.MorphiumId; + +import jakarta.json.bind.adapter.JsonbAdapter; + +/** + * JSON-B equivalent of {@link MorphiumIdJacksonModule}: maps {@link MorphiumId} + * to and from its canonical 24-character hex string so that REST endpoints using + * the JSON-B serialization layer ({@code quarkus-resteasy-jsonb} / + * {@code quarkus-rest-jsonb}) emit {@code "id":""} instead of the internal + * {@code {pid, counter, machineId, bytes, time}} struct. + * + * @see MorphiumIdJacksonModule for the rationale and the production bug this fixes + */ +public class MorphiumIdJsonbAdapter implements JsonbAdapter { + + @Override + public String adaptToJson(MorphiumId id) { + return id == null ? null : id.toString(); + } + + @Override + public MorphiumId adaptFromJson(String hex) { + return hex == null || hex.isBlank() ? null : new MorphiumId(hex); + } +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/json/MorphiumIdJsonbModule.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/json/MorphiumIdJsonbModule.java new file mode 100644 index 000000000..833a721c6 --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/json/MorphiumIdJsonbModule.java @@ -0,0 +1,40 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.json; + +import io.quarkus.jsonb.JsonbConfigCustomizer; + +import jakarta.inject.Singleton; +import jakarta.json.bind.JsonbConfig; + +/** + * Registers {@link MorphiumIdJsonbAdapter} on the application's JSON-B + * configuration so {@code MorphiumId} fields (de)serialize as a hex string — + * the JSON-B counterpart of {@link MorphiumIdJacksonModule}. + * + *

    This bean is registered automatically by the extension's build-time + * processor when {@code quarkus-jsonb} is on the classpath. JSON-B is an + * optional dependency of the extension, so this class is only loaded + * when a JSON-B-based JSON layer is actually present. + */ +@Singleton +public class MorphiumIdJsonbModule implements JsonbConfigCustomizer { + + @Override + public void customize(JsonbConfig config) { + config.withAdapters(new MorphiumIdJsonbAdapter()); + } +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/migration/Execution.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/migration/Execution.java new file mode 100644 index 000000000..c16646b25 --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/migration/Execution.java @@ -0,0 +1,43 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.migration; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a method inside a {@link MorphiumChangeUnit} as the migration execution method. + * + *

    The method may accept a single {@link de.caluga.morphium.Morphium} parameter + * or no parameters at all. + * + *

    Each {@link MorphiumChangeUnit} must have exactly one {@code @Execution} method. + * + *

    Must be idempotent. The changelog entry marking a change unit as executed is written + * only after this method returns successfully. If the process crashes (or is killed) + * between this method completing its work and that changelog write, the next run sees no + * changelog entry for this change unit and executes it again — the method's own effects (e.g. + * an insert that already succeeded once) must survive being applied a second time without + * corrupting data or throwing. Prefer {@code upsert} over unconditional insert, make deletes + * conditional on existence, and design any external side effect (a call to another service, a + * message published, etc.) to tolerate being triggered twice for the same logical migration run. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Execution { +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/migration/MorphiumChangeUnit.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/migration/MorphiumChangeUnit.java new file mode 100644 index 000000000..500e16cee --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/migration/MorphiumChangeUnit.java @@ -0,0 +1,66 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.migration; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a class as a Morphium database migration unit. + * + *

    Each change unit must contain exactly one method annotated with {@link Execution} + * and optionally one method annotated with {@link RollbackExecution}. + * + *

    Example: + *

    {@code
    + * @MorphiumChangeUnit(id = "001-init-products", order = "001", author = "team")
    + * public class InitProductsMigration {
    + *
    + *     @Execution
    + *     public void execute(Morphium morphium) {
    + *         morphium.store(new Product("Widget", 9.99));
    + *     }
    + *
    + *     @RollbackExecution
    + *     public void rollback(Morphium morphium) {
    + *         morphium.dropCollection(Product.class);
    + *     }
    + * }
    + * }
    + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface MorphiumChangeUnit { + + /** Unique identifier for this migration. Used to track execution state. */ + String id(); + + /** + * Execution order, used to sort migrations before running them. + * + *

    Compared numerically when both this and the other migration's {@code order()} value + * parse as a number (e.g. {@code "2"} sorts before {@code "10"}), falling back to a plain + * lexicographic string comparison otherwise -- so a non-numeric convention (e.g. date-based + * order values) is also supported. Zero-padded numbers (e.g. {@code "001"}, {@code "002"}) + * work correctly either way and remain the recommended convention for readability. + */ + String order(); + + /** Author of this migration (informational). */ + String author() default ""; +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/migration/MorphiumMigrationConfig.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/migration/MorphiumMigrationConfig.java new file mode 100644 index 000000000..abe3e2caf --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/migration/MorphiumMigrationConfig.java @@ -0,0 +1,68 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.migration; + +import io.smallrye.config.WithDefault; + +/** + * Nested configuration interface for database migrations. + * + *

    All properties live under the {@code quarkus.morphium.migration.*} prefix. + * + *

    Example {@code application.properties}: + *

    {@code
    + * quarkus.morphium.migration.migrate-at-start=true
    + * quarkus.morphium.migration.change-log-collection=morphiumChangeLog
    + * quarkus.morphium.migration.lock-collection=morphiumMigrationLock
    + * quarkus.morphium.migration.lock-ttl-seconds=60
    + * }
    + */ +public interface MorphiumMigrationConfig { + + /** + * Whether to run pending migrations automatically when the application starts. + * Defaults to {@code false} — migrations must be triggered explicitly unless enabled. + */ + @WithDefault("false") + boolean migrateAtStart(); + + /** Name of the MongoDB collection that tracks executed migrations. */ + @WithDefault("morphiumChangeLog") + String changeLogCollection(); + + /** Name of the MongoDB collection used for the distributed migration lock. */ + @WithDefault("morphiumMigrationLock") + String lockCollection(); + + /** + * Time-to-live in seconds for the migration lock. Prevents deadlocks from crashed processes. + * Must be greater than 0. The lock is renewed (heartbeat) after every executed migration, so + * this only needs to exceed the time a single change unit's {@code execute()} can take, not + * the whole migration run. + */ + @WithDefault("60") + int lockTtlSeconds(); + + /** + * Maximum time in seconds to wait for the migration lock if another instance already holds + * it, polling every second, before giving up and failing startup. Defaults to {@code 0} + * (fail immediately, the pre-existing behavior) — set this above {@code 0} in a multi-replica + * rolling deployment so that replicas whose pod starts while another replica is already + * running migrations wait for that run to finish instead of crash-looping. + */ + @WithDefault("0") + int lockWaitSeconds(); +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/migration/MorphiumMigrationEntry.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/migration/MorphiumMigrationEntry.java new file mode 100644 index 000000000..0afeef972 --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/migration/MorphiumMigrationEntry.java @@ -0,0 +1,89 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.migration; + +import de.caluga.morphium.annotations.Entity; +import de.caluga.morphium.annotations.Id; +import de.caluga.morphium.annotations.Property; + +import java.util.Date; + +/** + * Tracks applied database migrations. Each successfully executed + * {@link MorphiumChangeUnit} produces one entry in this collection. + */ +@Entity(collectionName = "morphiumChangeLog") +public class MorphiumMigrationEntry { + + public enum ChangeState { + EXECUTED, + ROLLED_BACK, + FAILED + } + + @Id + private String id; + + @Property(fieldName = "change_id") + private String changeId; + + @Property(fieldName = "author") + private String author; + + @Property(fieldName = "order") + private String order; + + @Property(fieldName = "migration_class") + private String className; + + @Property(fieldName = "executed_at") + private Date executedAt; + + @Property(fieldName = "execution_time_ms") + private long executionTimeMs; + + @Property(fieldName = "state") + private ChangeState state; + + public MorphiumMigrationEntry() { + } + + // --- accessors --- + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getChangeId() { return changeId; } + public void setChangeId(String changeId) { this.changeId = changeId; } + + public String getAuthor() { return author; } + public void setAuthor(String author) { this.author = author; } + + public String getOrder() { return order; } + public void setOrder(String order) { this.order = order; } + + public String getClassName() { return className; } + public void setClassName(String className) { this.className = className; } + + public Date getExecutedAt() { return executedAt; } + public void setExecutedAt(Date executedAt) { this.executedAt = executedAt; } + + public long getExecutionTimeMs() { return executionTimeMs; } + public void setExecutionTimeMs(long executionTimeMs) { this.executionTimeMs = executionTimeMs; } + + public ChangeState getState() { return state; } + public void setState(ChangeState state) { this.state = state; } +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/migration/MorphiumMigrationLock.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/migration/MorphiumMigrationLock.java new file mode 100644 index 000000000..d0505af7b --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/migration/MorphiumMigrationLock.java @@ -0,0 +1,62 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.migration; + +import de.caluga.morphium.annotations.Entity; +import de.caluga.morphium.annotations.Id; +import de.caluga.morphium.annotations.Property; + +import java.util.Date; + +/** + * Distributed lock entity for migration execution. Only one instance + * of the lock document (with a fixed {@code _id}) exists at a time. + * The lock contains an expiration timestamp used to treat the lock as + * expired and allow overriding stale locks after the configured TTL, + * helping to prevent deadlocks from crashed processes. + */ +@Entity(collectionName = "morphiumMigrationLock") +public class MorphiumMigrationLock { + + @Id + private String id; + + @Property(fieldName = "owner") + private String owner; + + @Property(fieldName = "acquired_at") + private Date acquiredAt; + + @Property(fieldName = "expires_at") + private Date expiresAt; + + public MorphiumMigrationLock() { + } + + // --- accessors --- + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getOwner() { return owner; } + public void setOwner(String owner) { this.owner = owner; } + + public Date getAcquiredAt() { return acquiredAt; } + public void setAcquiredAt(Date acquiredAt) { this.acquiredAt = acquiredAt; } + + public Date getExpiresAt() { return expiresAt; } + public void setExpiresAt(Date expiresAt) { this.expiresAt = expiresAt; } +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/migration/MorphiumMigrationRunner.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/migration/MorphiumMigrationRunner.java new file mode 100644 index 000000000..ec2d379e8 --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/migration/MorphiumMigrationRunner.java @@ -0,0 +1,742 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.migration; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.query.Query; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.management.ManagementFactory; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +/** + * Executes pending database migrations defined by {@link MorphiumChangeUnit} classes. + * + *

    Lifecycle: + *

      + *
    1. Acquire a distributed lock ({@code morphiumMigrationLock} collection)
    2. + *
    3. Load already-executed migrations from the changelog
    4. + *
    5. Discover and sort pending migrations by {@link MorphiumChangeUnit#order()}
    6. + *
    7. Execute each pending migration's {@link Execution} method
    8. + *
    9. Record success/failure in the changelog
    10. + *
    11. Release the lock
    12. + *
    + */ +public class MorphiumMigrationRunner { + + private static final Logger log = LoggerFactory.getLogger(MorphiumMigrationRunner.class); + + /** + * The fixed {@code _id} of the single migration-lock document. Package-visible (not + * {@code private}) and named consistently with the rest of the class so that tests in + * other packages needing the real lock-document id (e.g. to simulate a held lock) can + * reference this constant instead of duplicating it as a copy-pasted string literal -- + * a literal that would silently go stale and make such a test blind to its own bugs the + * moment this constant is renamed. Exposed via {@link #getLockId()} rather than made + * {@code public} directly, keeping the field itself an implementation detail while still + * giving test code (which lives in a different package, {@code + * de.caluga.morphium.quarkus.it}) a single, refactor-safe source of truth. + */ + static final String LOCK_ID = "migration_lock"; + + /** + * The in-flight lock heartbeat (see {@link #startLockHeartbeat}) wakes up roughly this many + * times per {@code lockTtlSeconds} window (subject to {@link #HEARTBEAT_MIN_INTERVAL_MS}), + * so the lock is renewed comfortably before it could expire even while a single change unit + * is still running. E.g. with the default {@code lockTtlSeconds=60} this fires every ~20s. + */ + private static final int HEARTBEAT_TICKS_PER_TTL = 3; + + /** + * Lower bound for the in-flight heartbeat's tick interval, in milliseconds. + * + *

    Purpose of the floor: purely to cap DB load for very small {@code lockTtlSeconds} -- + * without it, e.g. {@code lockTtlSeconds=1} with {@code HEARTBEAT_TICKS_PER_TTL=3} would + * otherwise tick every ~333ms, which is already fine, but even smaller TTLs could drive the + * interval towards zero and hammer the lock collection. It must NOT, however, be so large + * that it eats into (or exceeds) the TTL window itself for realistic {@code lockTtlSeconds} + * values: a previous version of this floor was 1000ms flat, which for {@code + * lockTtlSeconds=1} produced an interval EQUAL to the TTL -- i.e. the first heartbeat tick + * was scheduled to land exactly when the lock was already expiring, with zero margin for + * thread-start latency, GC pauses, or the {@code renewLock()} round-trip itself. That made + * the in-flight heartbeat unable to ever renew in time for small TTLs, which is a real user + * -facing bug (anyone configuring a short {@code lockTtlSeconds}, not just tests) and not + * merely a test-timing artifact. + * + *

    200ms is chosen as a floor that is small enough to still leave several ticks (and + * therefore several renewal attempts with real safety margin) inside a 1s TTL, while still + * being coarse enough that normal-to-large TTLs (seconds to minutes) are completely + * unaffected -- {@code HEARTBEAT_TICKS_PER_TTL}'s natural interval already exceeds 200ms for + * any {@code lockTtlSeconds >= 1}, so the floor only ever engages for sub-second TTLs. + */ + private static final long HEARTBEAT_MIN_INTERVAL_MS = 200L; + + private final Morphium morphium; + private final MorphiumMigrationConfig config; + + /** Owner identifier for this runner instance, set during {@link #acquireLock()}. */ + private String currentOwner; + + /** + * Set by the in-flight lock heartbeat (see {@link #startLockHeartbeat}) if it detects, + * while a single change unit is still running, that the lock has been taken over by + * another process. Read and cleared by {@link #executeMigration} right after the unit + * finishes so the failure is never silently swallowed -- it is always either the primary + * exception thrown from {@code executeMigration}, or attached as a suppressed exception on + * the migration's own failure if both happened. + */ + private final AtomicReference heartbeatFailure = new AtomicReference<>(); + + public MorphiumMigrationRunner(Morphium morphium, MorphiumMigrationConfig config) { + this.morphium = morphium; + this.config = config; + validateConfig(); + } + + /** + * Returns the fixed {@code _id} of the migration-lock document used by this runner. + * Intended for tests (and diagnostic tooling) that need to reason about the lock document + * directly -- e.g. to simulate a held lock -- without duplicating {@link #LOCK_ID} as a + * copy-pasted string literal that would silently go stale if the constant is ever renamed. + */ + public static String getLockId() { + return LOCK_ID; + } + + /** + * Runs all pending migrations from the given list of change-unit class names. + * + * @param changeUnitClassNames fully qualified class names of {@link MorphiumChangeUnit} classes + * @throws RuntimeException if a migration fails + */ + public void execute(List changeUnitClassNames) { + if (changeUnitClassNames == null || changeUnitClassNames.isEmpty()) { + log.info("No @MorphiumChangeUnit classes found — skipping migrations"); + return; + } + + List migrations = resolveMigrations(changeUnitClassNames); + if (migrations.isEmpty()) { + log.info("No valid @MorphiumChangeUnit classes found — skipping migrations"); + return; + } + + validateUniqueIds(migrations); + migrations.sort(MorphiumMigrationRunner::compareByOrder); + log.info("Found {} migration(s) to evaluate", migrations.size()); + + acquireLockWithWait(); + try { + Set executedIds = loadExecutedChangeIds(); + for (MigrationInfo migration : migrations) { + if (executedIds.contains(migration.changeId())) { + log.debug("Skipping already executed migration: {} ({})", migration.changeId(), migration.className()); + continue; + } + executeMigration(migration); + // Renew the lock's TTL after every executed migration: without this, a + // migration run that takes longer than lockTtlSeconds lets a second instance + // atomically steal the lock (acquireLock()'s expires_at <= now condition would + // match) and start running the SAME still-in-progress change units + // concurrently. Owner-guarded -- but NOT a silent no-op if another process has + // already taken over: renewLock() inspects the owner-guarded update's matched + // count ("n"), exactly like acquireLock() does, and throws when it is 0, + // aborting this run immediately instead of continuing. This matters because + // only releaseLock() is owner-guarded against a lost lock -- recordExecution() + // and the change units themselves are NOT, so silently continuing here would + // let this instance keep writing (changelog entries, change-unit side effects) + // concurrently with whatever process now legitimately owns the lock. This call + // renews between change units; the separate in-flight heartbeat started inside + // executeMigration() additionally renews WHILE a single change unit is still + // executing, closing the gap where one unit alone runs longer than + // lockTtlSeconds. + renewLock(); + } + } finally { + releaseLock(); + } + + log.info("All migrations completed successfully"); + } + + // ------------------------------------------------------------------ + // Configuration validation + // ------------------------------------------------------------------ + + private void validateConfig() { + if (config.lockTtlSeconds() <= 0) { + throw new IllegalArgumentException( + "quarkus.morphium.migration.lock-ttl-seconds must be > 0, got: " + config.lockTtlSeconds()); + } + } + + private void validateUniqueIds(List migrations) { + Set seen = new HashSet<>(); + for (MigrationInfo m : migrations) { + if (m.changeId() == null || m.changeId().isBlank()) { + throw new IllegalStateException("@MorphiumChangeUnit " + m.className() + + " has an empty id — a non-blank id is required."); + } + if (!seen.add(m.changeId())) { + throw new IllegalStateException("Duplicate @MorphiumChangeUnit id '" + + m.changeId() + "' — each migration must have a unique id."); + } + } + } + + /** + * Compares two migrations by {@link MorphiumChangeUnit#order()} numerically when both + * values parse as a {@code long}, falling back to a plain lexicographic string comparison + * otherwise. + * + *

    {@code order()} is a {@code String}, not a number, so {@code Comparator.comparing} + * on it directly sorts lexicographically: {@code "10"} sorts BEFORE {@code "2"} (because + * {@code '1' < '2'} as characters), silently reordering migrations once there are more than + * 9 of them unless every {@code order} value happens to be zero-padded to the same width + * (the convention every migration in this codebase's own tests already follows, which is + * exactly why this was never caught by them). Falling back to lexicographic comparison for + * non-numeric values keeps this compatible with a date-based or other non-numeric ordering + * convention some users may already rely on. + */ + static int compareByOrder(MigrationInfo a, MigrationInfo b) { + Long numA = tryParseLong(a.order()); + Long numB = tryParseLong(b.order()); + if (numA != null && numB != null) { + return Long.compare(numA, numB); + } + return a.order().compareTo(b.order()); + } + + private static Long tryParseLong(String s) { + try { + return Long.parseLong(s); + } catch (NumberFormatException e) { + return null; + } + } + + // ------------------------------------------------------------------ + // Migration resolution + // ------------------------------------------------------------------ + + private List resolveMigrations(List classNames) { + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + List result = new ArrayList<>(); + + for (String className : classNames) { + try { + Class clazz = Class.forName(className, true, cl); + MorphiumChangeUnit annotation = clazz.getAnnotation(MorphiumChangeUnit.class); + if (annotation == null) { + log.warn("Class {} is not annotated with @MorphiumChangeUnit — skipping", className); + continue; + } + + Method execMethod = findAnnotatedMethod(clazz, Execution.class, true); + Method rollbackMethod = findAnnotatedMethod(clazz, RollbackExecution.class, false); + + result.add(new MigrationInfo( + annotation.id(), + annotation.order(), + annotation.author(), + className, + clazz, + execMethod, + rollbackMethod)); + + } catch (ClassNotFoundException e) { + log.warn("Could not load migration class: {} — skipping", className); + } + } + + return result; + } + + private Method findAnnotatedMethod(Class clazz, Class annotation, + boolean required) { + Method found = null; + for (Method m : clazz.getDeclaredMethods()) { + if (m.isAnnotationPresent(annotation)) { + if (found != null) { + throw new IllegalStateException("Class " + clazz.getName() + + " has multiple methods annotated with @" + + annotation.getSimpleName() + + (required ? " — exactly one is required." : " — at most one is allowed.")); + } + m.setAccessible(true); + found = m; + } + } + if (found == null && required) { + throw new IllegalStateException("@MorphiumChangeUnit " + clazz.getName() + + " has no @" + annotation.getSimpleName() + " method — exactly one is required."); + } + return found; + } + + // ------------------------------------------------------------------ + // Migration execution + // ------------------------------------------------------------------ + + private void executeMigration(MigrationInfo migration) { + log.info("Executing migration: {} (order={}, author={})", + migration.changeId(), migration.order(), migration.author()); + + long startTime = System.currentTimeMillis(); + Object instance; + try { + instance = migration.clazz().getDeclaredConstructor().newInstance(); + } catch (Exception e) { + throw new RuntimeException("Cannot instantiate migration class " + migration.className() + + ". Ensure it has a public no-arg constructor.", e); + } + + Thread heartbeat = startLockHeartbeat(migration.changeId()); + try { + try { + invokeMigrationMethod(migration.execMethod(), instance); + } catch (Exception e) { + long elapsed = System.currentTimeMillis() - startTime; + RuntimeException lost = stopLockHeartbeat(heartbeat); + + if (lost != null) { + // The change unit itself also failed (its own exception, e, is the primary + // cause below); attach the lock-loss failure as a suppressed exception so + // both are visible together instead of losing one of them. + e.addSuppressed(lost); + } + + recordExecution(migration, elapsed, MorphiumMigrationEntry.ChangeState.FAILED); + log.error("Migration {} failed after {}ms", migration.changeId(), elapsed, e); + + RuntimeException failure = new RuntimeException("Migration " + migration.changeId() + " failed", e); + if (migration.rollbackMethod() != null) { + // If the rollback itself also fails, that failure must not be silently swallowed + // (previously only logged) -- the database can be left in an unknown + // intermediate state (migration partially applied, rollback partially/not + // applied), and losing the rollback failure's details makes that state much + // harder to diagnose. Attached as a suppressed exception on the original + // migration failure, so both are visible together wherever this exception is + // logged or reported, without changing what actually gets thrown (the original + // migration failure remains the primary cause, per existing behavior/tests). + tryRollback(migration, instance).ifPresent(failure::addSuppressed); + } + + throw failure; + } + + // The @Execution method itself completed normally; still need to check whether the + // heartbeat discovered mid-run that the lock had already been taken over. Handled + // here, outside the try/catch above, so this lock-loss failure is reported on its + // own terms instead of being caught and re-wrapped as a generic migration failure. + long elapsed = System.currentTimeMillis() - startTime; + RuntimeException lost = stopLockHeartbeat(heartbeat); + if (lost != null) { + // The unit itself finished, but the heartbeat detected mid-run that the lock had + // already been taken over -- treat this exactly like a lock loss detected by + // renewLock() between units: the run must not continue (recordExecution() below, + // and any subsequent units, are NOT owner-guarded). + recordExecution(migration, elapsed, MorphiumMigrationEntry.ChangeState.FAILED); + throw lost; + } + + recordExecution(migration, elapsed, MorphiumMigrationEntry.ChangeState.EXECUTED); + log.info("Migration {} completed in {}ms", migration.changeId(), elapsed); + } finally { + stopLockHeartbeat(heartbeat); + } + } + + private void invokeMigrationMethod(Method method, Object instance) throws Exception { + Class[] paramTypes = method.getParameterTypes(); + if (paramTypes.length == 0) { + method.invoke(instance); + } else if (paramTypes.length == 1 && Morphium.class.isAssignableFrom(paramTypes[0])) { + method.invoke(instance, morphium); + } else { + throw new IllegalArgumentException("@Execution/@RollbackExecution method " + method.getName() + + " must accept either no parameters or a single Morphium parameter"); + } + } + + /** + * Attempts to run the migration's {@code @RollbackExecution} method after the migration + * itself failed, and updates the changelog entry to {@code ROLLED_BACK} on success. + * + * @return the rollback's own exception if it also failed, so the caller can attach it + * (e.g. as a suppressed exception) to the original migration failure instead of + * losing it; {@link Optional#empty()} if the rollback succeeded or there was + * nothing to roll back + */ + private Optional tryRollback(MigrationInfo migration, Object instance) { + try { + log.info("Attempting rollback for migration: {}", migration.changeId()); + invokeMigrationMethod(migration.rollbackMethod(), instance); + log.info("Rollback for {} completed successfully", migration.changeId()); + + // Update the changelog entry to ROLLED_BACK + Query q = morphium.createQueryFor(MorphiumMigrationEntry.class); + q.setCollectionName(config.changeLogCollection()); + q.f("_id").eq(migration.changeId()); + MorphiumMigrationEntry entry = q.get(); + + if (entry != null) { + entry.setState(MorphiumMigrationEntry.ChangeState.ROLLED_BACK); + morphium.store(entry, config.changeLogCollection(), null); + } + return Optional.empty(); + } catch (Exception re) { + log.error("Rollback for {} also failed", migration.changeId(), re); + return Optional.of(re); + } + } + + // ------------------------------------------------------------------ + // Changelog tracking + // ------------------------------------------------------------------ + + /** + * Loads the set of change IDs that have already been executed successfully. + * Called once before the migration loop to avoid N+1 queries. + */ + private Set loadExecutedChangeIds() { + Query q = morphium.createQueryFor(MorphiumMigrationEntry.class); + q.setCollectionName(config.changeLogCollection()); + q.f("state").eq(MorphiumMigrationEntry.ChangeState.EXECUTED.name()); + return q.asList().stream() + .map(MorphiumMigrationEntry::getChangeId) + .collect(Collectors.toSet()); + } + + private void recordExecution(MigrationInfo migration, long executionTimeMs, + MorphiumMigrationEntry.ChangeState state) { + MorphiumMigrationEntry entry = new MorphiumMigrationEntry(); + entry.setId(migration.changeId()); + entry.setChangeId(migration.changeId()); + entry.setAuthor(migration.author()); + entry.setOrder(migration.order()); + entry.setClassName(migration.className()); + entry.setExecutedAt(new Date()); + entry.setExecutionTimeMs(executionTimeMs); + entry.setState(state); + morphium.store(entry, config.changeLogCollection(), null); + } + + // ------------------------------------------------------------------ + // Distributed lock + // ------------------------------------------------------------------ + + /** + * Acquires the migration lock, waiting up to {@code lockWaitSeconds} (polling every second) + * if another instance already holds it, before giving up. With the default + * {@code lockWaitSeconds=0} this is identical to calling {@link #acquireLock()} directly. + * + *

    Without this, a k8s rolling deployment with multiple replicas crash-loops every replica + * except the one that happened to win the lock race, until the migration run finishes and + * the lock is released — instead of the other replicas simply waiting their turn. + * + * @throws RuntimeException if the lock is still held by another process after the wait + */ + private void acquireLockWithWait() { + long deadline = System.currentTimeMillis() + config.lockWaitSeconds() * 1000L; + while (true) { + try { + acquireLock(); + return; + } catch (RuntimeException e) { + if (System.currentTimeMillis() >= deadline) { + throw e; + } + log.info("Migration lock held by another instance — waiting (owner={})", currentOwner); + try { + Thread.sleep(1000L); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw e; + } + } + } + } + + /** + * Acquires the migration lock atomically using {@code findAndModify} with {@code upsert: true}. + * + *

    The query matches a lock document that either does not exist or has expired. + * The atomic update sets the new owner and expiration in one round-trip, preventing + * the race condition where two instances could both read "no lock" and then both write. + * + *

    Client clock skew: {@code expires_at} is computed from this process's local + * clock ({@code System.currentTimeMillis()}), not the MongoDB server's clock. If two + * instances' clocks drift apart by more than a small fraction of {@code lockTtlSeconds}, the + * instance with the faster clock can see the other's still-valid lock as already expired and + * take it over while the original holder is still actively running migrations. Morphium/the + * MongoDB driver used here has no update-pipeline support for a server-computed expiry + * (MongoDB 4.2+'s {@code $$NOW} in aggregation-pipeline updates would be the correct + * primitive, but nothing in this codebase issues one), so this is a real, currently + * unaddressed limitation, not a false alarm — the accepted mitigation is what every + * NTP-less distributed lock already requires: keep replica clocks synchronized (NTP/chrony), + * and set {@code lockTtlSeconds} generously above the expected clock drift, not just above + * the expected migration runtime. + * + *

    If the lock is held by another process and has not expired, the method throws. + * Callers that want to wait for a currently-held lock to become available should call + * {@link #acquireLockWithWait()} instead. + * + * @throws RuntimeException if the lock is held by another process + */ + private void acquireLock() { + currentOwner = getOwnerIdentifier(); + log.debug("Acquiring migration lock (owner={})", currentOwner); + + Date now = new Date(); + Date expiresAt = new Date(now.getTime() + config.lockTtlSeconds() * 1000L); + + // Atomic: match _id=LOCK_ID where lock is expired (or does not exist via upsert), + // then $set owner, acquired_at, expires_at in one round-trip. + Query q = morphium.createQueryFor(MorphiumMigrationLock.class); + q.setCollectionName(config.lockCollection()); + q.f("_id").eq(LOCK_ID); + q.f("expires_at").lte(now); + + Map update = Map.of( + "owner", currentOwner, + "acquired_at", now, + "expires_at", expiresAt); + + try { + var result = q.set(update, true, false); + + // MongoDB returns: n (matched count), nModified, ok, and upserted (array) on upsert. + // If n==0 and no upsert happened, the lock is held by another process. + if (result == null) { + throwLockHeld(); + return; + } + + Object n = result.get("n"); + Object upserted = result.get("upserted"); + long matchedCount = n instanceof Number num ? num.longValue() : 0; + boolean wasUpserted = upserted != null; + + if (matchedCount == 0 && !wasUpserted) { + throwLockHeld(); + return; + } + } catch (RuntimeException e) { + // DuplicateKeyError when _id exists but expires_at condition didn't match (lock still active) + if (e.getMessage() != null && e.getMessage().contains("duplicate key")) { + throwLockHeld(); + return; + } + throw e; + } + + log.debug("Migration lock acquired (TTL={}s)", config.lockTtlSeconds()); + } + + /** + * Extends the lock's {@code expires_at} by another {@code lockTtlSeconds}, guarded by + * {@code owner=currentOwner}. Called after every executed migration by {@link #execute} + * — see the call site for why a heartbeat is needed at all. + * + *

    Evaluates the owner-guarded update's matched count ("n"), exactly like + * {@link #acquireLock()} does: if it is {@code 0}, another process has already taken over + * the lock (e.g. because a previous renewal round-trip was slow enough for the old TTL to + * expire first, or a genuine steal happened while this instance was busy). In that case + * this method throws instead of returning silently, so the caller aborts the migration run + * rather than continuing to execute change units and write changelog entries concurrently + * with the new owner. + * + * @throws RuntimeException if the lock is no longer held by this instance (matched count 0) + */ + private void renewLock() { + Date expiresAt = new Date(System.currentTimeMillis() + config.lockTtlSeconds() * 1000L); + Query q = morphium.createQueryFor(MorphiumMigrationLock.class); + q.setCollectionName(config.lockCollection()); + q.f("_id").eq(LOCK_ID); + q.f("owner").eq(currentOwner); + + Map result; + try { + result = q.set(Map.of("expires_at", expiresAt), false, false); + } catch (Exception e) { + // Best-effort for a failure of the round-trip itself (e.g. a transient network + // error): the original TTL still applies, and either the next renewal attempt or + // acquireLock()'s next caller will simply observe the lock sooner than the full TTL + // would suggest. This is distinct from -- and less severe than -- an explicit + // matched-count-0 result below, which proves the lock was DEFINITELY already taken + // over and must abort the run. + log.warn("Failed to renew migration lock (owner={})", currentOwner, e); + return; + } + + long matchedCount = 0; + if (result != null) { + Object n = result.get("n"); + matchedCount = n instanceof Number num ? num.longValue() : 0; + } + + if (matchedCount == 0) { + throw new RuntimeException("Migration lock was lost while migrations were still running (owner=" + + currentOwner + "). Another process has already taken over the lock '" + LOCK_ID + + "' in collection '" + config.lockCollection() + + "' -- aborting this run to avoid executing change units concurrently with the new owner."); + } + } + + // ------------------------------------------------------------------ + // In-flight lock heartbeat + // ------------------------------------------------------------------ + + /** + * Starts a daemon heartbeat thread that periodically renews the lock for the duration of a + * single, potentially long-running change unit's {@code @Execution} method. + * + *

    {@link #renewLock()} alone only renews the lock between change units. A + * single unit that itself runs longer than {@code lockTtlSeconds} (e.g. building an index + * on a large collection) would otherwise let another instance atomically take over the + * lock and start running that very same unit concurrently, while the original instance is + * still inside its (unaware) {@code invoke()} call. This heartbeat closes that gap by + * renewing on a fixed schedule (roughly {@link #HEARTBEAT_TICKS_PER_TTL} times per TTL + * window, floored at {@link #HEARTBEAT_MIN_INTERVAL_MS}) for as long as the unit is + * executing. + * + *

    The thread is a daemon so it can never prevent JVM shutdown by itself, and always + * terminates via {@link #stopLockHeartbeat} in a {@code finally} block around the unit's + * execution, so it never outlives the unit it was started for. If a heartbeat tick + * discovers the lock has been taken over (matched count 0 on the owner-guarded update), it + * records that as a {@link RuntimeException} in {@link #heartbeatFailure} and stops ticking + * -- it deliberately does NOT interrupt the running {@code @Execution} method itself (Java + * has no safe way to abort arbitrary user code), but the caller checks {@code + * heartbeatFailure} as soon as the unit returns (successfully or not) and surfaces the + * failure instead of silently accepting the unit's result. + * + * @return the heartbeat thread; always non-null, always already started + */ + private Thread startLockHeartbeat(String changeId) { + heartbeatFailure.set(null); + long intervalMs = Math.max(HEARTBEAT_MIN_INTERVAL_MS, + (config.lockTtlSeconds() * 1000L) / HEARTBEAT_TICKS_PER_TTL); + + Thread thread = new Thread(() -> { + while (!Thread.currentThread().isInterrupted()) { + try { + Thread.sleep(intervalMs); + } catch (InterruptedException ie) { + return; + } + try { + renewLock(); + log.debug("In-flight lock heartbeat renewed lock while executing {} (owner={})", + changeId, currentOwner); + } catch (RuntimeException lockLost) { + // Not swallowed: recorded for executeMigration() to pick up and surface as + // soon as the (still-running) change unit returns. + heartbeatFailure.set(lockLost); + log.error("In-flight lock heartbeat detected the migration lock was lost while " + + "executing {} (owner={})", changeId, currentOwner, lockLost); + return; + } + } + }, "morphium-migration-lock-heartbeat"); + thread.setDaemon(true); + thread.start(); + return thread; + } + + /** + * Stops the heartbeat thread started by {@link #startLockHeartbeat} and returns whatever + * lock-loss failure it may have recorded, so the caller can surface it instead of letting + * it disappear silently. Safe to call more than once for the same thread (e.g. from both + * the normal-completion path and a {@code finally} block) -- interrupting an already-dead + * thread, or joining one that already finished, is a no-op. + */ + private RuntimeException stopLockHeartbeat(Thread heartbeat) { + heartbeat.interrupt(); + try { + heartbeat.join(1000L); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } + return heartbeatFailure.getAndSet(null); + } + + private void throwLockHeld() { + // Read the current lock to provide a helpful error message + Query readQ = morphium.createQueryFor(MorphiumMigrationLock.class); + readQ.setCollectionName(config.lockCollection()); + readQ.f("_id").eq(LOCK_ID); + MorphiumMigrationLock existing = readQ.get(); + + String detail = existing != null + ? "held by '" + existing.getOwner() + "' (acquired at " + existing.getAcquiredAt() + + ", expires at " + existing.getExpiresAt() + ")" + : "in unknown state"; + + throw new RuntimeException("Migration lock is " + detail + + ". If this is stale, wait for TTL expiry or manually remove the lock " + + "document with _id='" + LOCK_ID + "' from the '" + + config.lockCollection() + "' collection."); + } + + /** + * Releases the migration lock, but only if this runner still owns it. + * If the lock was overridden (e.g., after TTL expiry by another instance), + * the lock is not deleted to avoid removing another process's valid lock. + */ + private void releaseLock() { + try { + Query q = morphium.createQueryFor(MorphiumMigrationLock.class); + q.setCollectionName(config.lockCollection()); + q.f("_id").eq(LOCK_ID); + q.f("owner").eq(currentOwner); + morphium.delete(q); + log.debug("Migration lock released"); + } catch (Exception e) { + log.warn("Failed to release migration lock", e); + } + } + + private String getOwnerIdentifier() { + String pid = ManagementFactory.getRuntimeMXBean().getName(); + return pid + "@" + System.currentTimeMillis(); + } + + // ------------------------------------------------------------------ + // Internal model + // ------------------------------------------------------------------ + + record MigrationInfo( + String changeId, + String order, + String author, + String className, + Class clazz, + Method execMethod, + Method rollbackMethod + ) { + } +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/migration/RollbackExecution.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/migration/RollbackExecution.java new file mode 100644 index 000000000..5e6d2dbf9 --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/migration/RollbackExecution.java @@ -0,0 +1,35 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.migration; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a method inside a {@link MorphiumChangeUnit} as the rollback method. + * + *

    The method may accept a single {@link de.caluga.morphium.Morphium} parameter + * or no parameters at all. It is called when the corresponding {@link Execution} + * method fails. + * + *

    This annotation is optional — not every migration needs a rollback. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface RollbackExecution { +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/transaction/MorphiumTransactionEvent.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/transaction/MorphiumTransactionEvent.java new file mode 100644 index 000000000..fc88bcccf --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/transaction/MorphiumTransactionEvent.java @@ -0,0 +1,50 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.transaction; + +/** + * CDI event fired by {@link MorphiumTransactionalInterceptor} at various + * transaction lifecycle phases. + */ +public class MorphiumTransactionEvent { + + public enum Phase { + BEFORE_COMMIT, + AFTER_COMMIT, + AFTER_ROLLBACK + } + + private final Phase phase; + private final Exception failure; + + public MorphiumTransactionEvent(Phase phase) { + this(phase, null); + } + + public MorphiumTransactionEvent(Phase phase, Exception failure) { + this.phase = phase; + this.failure = failure; + } + + public Phase getPhase() { + return phase; + } + + /** Non-null only for {@link Phase#AFTER_ROLLBACK}. */ + public Exception getFailure() { + return failure; + } +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/transaction/MorphiumTransactional.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/transaction/MorphiumTransactional.java new file mode 100644 index 000000000..2d3bc0e33 --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/transaction/MorphiumTransactional.java @@ -0,0 +1,32 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.transaction; + +import jakarta.interceptor.InterceptorBinding; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Interceptor binding that wraps the annotated method (or all methods of a class) + * in a Morphium transaction. On success the transaction is committed; on exception + * it is rolled back and the exception is re-thrown. + */ +@InterceptorBinding +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface MorphiumTransactional {} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/transaction/MorphiumTransactionalInterceptor.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/transaction/MorphiumTransactionalInterceptor.java new file mode 100644 index 000000000..36c48c60a --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/transaction/MorphiumTransactionalInterceptor.java @@ -0,0 +1,428 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.transaction; + +import org.jboss.logging.Logger; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.driver.MorphiumDriverException; +import de.caluga.morphium.driver.MorphiumTransactionContext; +import de.caluga.morphium.quarkus.transaction.MorphiumTransactionEvent.Phase; +import jakarta.enterprise.event.Event; +import jakarta.inject.Inject; +import jakarta.interceptor.AroundInvoke; +import jakarta.interceptor.Interceptor; +import jakarta.interceptor.InvocationContext; + +import java.util.concurrent.CompletionStage; + +/** + * CDI interceptor that wraps methods annotated with {@link MorphiumTransactional} + * in a Morphium transaction. + * + *

      + *
    • Fires {@link Phase#BEFORE_COMMIT} before committing.
    • + *
    • Fires {@link Phase#AFTER_COMMIT} after a successful commit.
    • + *
    • On exception: aborts, fires {@link Phase#AFTER_ROLLBACK}, re-throws.
    • + *
    • On transient MongoDB errors (WriteConflict 112, NoSuchTransaction 251): + * retries the entire transaction up to 3 times with exponential backoff.
    • + *
    • On CosmosDB: skips transaction wrapping but still fires lifecycle events + * ({@code BEFORE_COMMIT}/{@code AFTER_COMMIT} on success, {@code AFTER_ROLLBACK} + * on exception) so that observers continue to work. A one-time WARN is logged + * at startup and per-call at DEBUG.
    • + *
    + */ +@MorphiumTransactional +@Interceptor +@jakarta.annotation.Priority(Interceptor.Priority.PLATFORM_BEFORE + 200) +public class MorphiumTransactionalInterceptor { + + private static final Logger log = Logger.getLogger(MorphiumTransactionalInterceptor.class); + + @Inject + Morphium morphium; + + @Inject + @MorphiumTxPhase(Phase.BEFORE_COMMIT) + Event beforeCommit; + + @Inject + @MorphiumTxPhase(Phase.AFTER_COMMIT) + Event afterCommit; + + @Inject + @MorphiumTxPhase(Phase.AFTER_ROLLBACK) + Event afterRollback; + + private volatile Boolean cosmosDb; + + private boolean isCosmosDb() { + Boolean cached = cosmosDb; + if (cached != null) { + return cached; + } + synchronized (this) { + if (cosmosDb != null) { + return cosmosDb; + } + try { + cosmosDb = morphium.getDriver().isCosmosDB(); + } catch (Exception e) { + // Fail-open to false (standard MongoDB) here is intentional, not an oversight: + // this is a best-effort cache, not the only safety net. If the backend actually + // IS CosmosDB and this detection call failed only transiently, startTransaction() + // below throws UnsupportedOperationException on the very next call and that + // catch block corrects cosmosDb to true immediately -- see its comment. The + // worst case here is one avoidable failed startTransaction() attempt before + // self-correcting, never a silently wrong steady state. + log.warnf("Could not determine if backend is CosmosDB; assuming standard MongoDB. Cause: %s", + e.getMessage()); + cosmosDb = false; + } + if (cosmosDb) { + log.warn("CosmosDB detected — @MorphiumTransactional methods will execute " + + "WITHOUT transaction wrapping. Individual ops remain atomic; " + + "multi-document rollback is unavailable."); + } + return cosmosDb; + } + } + + @AroundInvoke + Object aroundInvoke(InvocationContext ctx) throws Throwable { + Class returnType = ctx.getMethod().getReturnType(); + if (isAsyncReturnType(returnType)) { + // Fail fast instead of silently doing the wrong thing: ctx.proceed() below returns + // the CompletionStage/Uni object itself immediately (the method body hasn't + // actually finished running its async work yet), so committing/firing + // AFTER_COMMIT right after ctx.proceed() returns would commit the transaction + // before the method's actual database writes (which typically run later, on + // repo.getAsyncExecutor() or a Mutiny scheduler) have even happened. There is no + // reliable way for this synchronous CDI interceptor to hook "when the returned + // CompletionStage/Uni completes" without materially changing what @MorphiumTransactional + // does, so this is unsupported until that's built deliberately -- not silently wrong. + throw new UnsupportedOperationException( + "@MorphiumTransactional does not support asynchronous return types (found " + + returnType.getName() + " on " + ctx.getMethod().getDeclaringClass().getSimpleName() + + "." + ctx.getMethod().getName() + "()). The interceptor commits " + + "immediately after ctx.proceed() returns, which happens before an async " + + "method's actual work completes -- use a synchronous method (or the " + + "repository's doXxxAsync methods called from within a synchronous " + + "@MorphiumTransactional method, so their CompletionStage is awaited " + + "before the method returns) instead."); + } + + // CosmosDB: execute without transaction wrapping but still fire lifecycle events + if (isCosmosDb()) { + log.debugf("CosmosDB: @MorphiumTransactional on %s.%s executes WITHOUT transaction.", + ctx.getMethod().getDeclaringClass().getSimpleName(), + ctx.getMethod().getName()); + return proceedWithEvents(ctx); + } + + // REQUIRED propagation: if a transaction is already active, just participate + if (morphium.getTransaction() != null) { + log.debugf("Joining existing transaction for %s.%s", + ctx.getMethod().getDeclaringClass().getSimpleName(), + ctx.getMethod().getName()); + return ctx.proceed(); + } + + try { + morphium.startTransaction(); + } catch (UnsupportedOperationException e) { + // Defensive fallback: detection missed CosmosDB (e.g. driver not yet connected at first check) + cosmosDb = true; + log.warn("startTransaction() threw UnsupportedOperationException — " + + "switching to CosmosDB mode for all future invocations."); + return proceedWithEvents(ctx); + } + + // Disable the write buffer for this thread while the transaction is active. + // BufferedMorphiumWriter flushes on a background thread that does NOT + // participate in the transaction — writes would bypass the transaction scope. + // Save the current state so we only re-enable if it was enabled before, + // avoiding clobbering a caller that had already disabled the write buffer. + boolean writeBufferWasEnabled = morphium.isWriteBufferEnabledForThread(); + if (writeBufferWasEnabled) { + morphium.disableWriteBufferForThread(); + } + int maxRetries = 3; + try { + for (int attempt = 0; ; attempt++) { + Object result; + try { + result = ctx.proceed(); + } catch (Throwable t) { + // catch (Throwable), not (Exception): an Error (e.g. OutOfMemoryError, + // StackOverflowError) must still trigger safeAbort() -- otherwise the + // transaction context stays open on this thread, and a later invocation + // reusing the same (pooled) thread would silently "join" a dead + // transaction via the REQUIRED-propagation check above. + safeAbort(); + if (!(t instanceof Exception e)) { + // Errors are never retried; rethrow as-is (no lifecycle event, + // matching how an unrecoverable JVM-level failure should propagate). + throw t; + } + if (attempt < maxRetries && isTransientTransactionError(e)) { + log.warnf("Transient transaction error on %s.%s (attempt %d/%d) — retrying entire transaction: %s", + ctx.getMethod().getDeclaringClass().getSimpleName(), + ctx.getMethod().getName(), + attempt + 1, maxRetries, + e.getMessage()); + try { + long backoffMs = 50L * (1L << attempt); // exponential: 50, 100, 200ms + Thread.sleep(Math.min(backoffMs, 1000L)); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + afterRollback.fire(new MorphiumTransactionEvent(Phase.AFTER_ROLLBACK, e)); + throw e; + } + morphium.startTransaction(); + continue; + } + afterRollback.fire(new MorphiumTransactionEvent(Phase.AFTER_ROLLBACK, e)); + throw e; + } + + // The business method itself succeeded -- from here on, a transient error + // must retry ONLY the commit, never re-run ctx.proceed(). MongoDB drivers + // retry a transient commit failure (e.g. code 251/NoSuchTransaction after a + // failover where the server actually committed but the reply was lost) by + // resending the commit, exactly for this reason: re-running the statements + // that already ran inside the (possibly already-committed) transaction would + // apply them a second time. safeCommitWithRetry() below handles that retry + // internally and never re-invokes ctx.proceed(). + // + // BEFORE_COMMIT below fires exactly once per successful ctx.proceed() -- it is + // outside safeCommitWithRetry()'s own internal retry loop, so a transient commit + // retry does NOT re-fire it (observers that key idempotency/outbox logic off this + // event would otherwise see it multiple times for what is really the same logical + // commit attempt). It only fires again if the OUTER loop above re-runs + // ctx.proceed() from scratch, which is a genuinely new transaction attempt. + try { + beforeCommit.fire(new MorphiumTransactionEvent(Phase.BEFORE_COMMIT)); + safeCommitWithRetry(ctx); + afterCommit.fire(new MorphiumTransactionEvent(Phase.AFTER_COMMIT)); + return result; + } catch (Throwable t) { + safeAbort(); + if (t instanceof Exception e) { + afterRollback.fire(new MorphiumTransactionEvent(Phase.AFTER_ROLLBACK, e)); + throw e; + } + throw t; + } + } + } finally { + if (writeBufferWasEnabled) { + morphium.enableWriteBufferForThread(); + } + } + } + + /** + * Commits the current transaction, tolerating the case where no server-side + * transaction exists (e.g. when all repository calls were mocked in tests + * and no actual DB operations reached the server). + */ + private void safeCommit() throws MorphiumDriverException { + if (morphium.getTransaction() == null) { + return; + } + try { + morphium.commitTransaction(); + } catch (MorphiumDriverException e) { + if (isNoServerTransaction(e)) { + log.debugf("No server-side transaction to commit (no DB operations occurred): %s", + e.getMessage()); + } else { + throw e; + } + } + } + + /** + * Commits the current transaction, retrying ONLY the commit itself (never + * {@code ctx.proceed()}) up to {@code maxRetries} times when a transient MongoDB error + * ({@link #isTransientTransactionError}) occurs. This mirrors how MongoDB drivers handle a + * transient commit failure internally: a failed commit whose underlying write may have + * actually succeeded on the server (the reply was merely lost, e.g. during a primary + * failover -- code 251/NoSuchTransaction) is retried by resending the commit, never by + * re-running the original statements. Re-running the whole {@code @MorphiumTransactional} + * method here would apply every write inside it a second time. + * + * @param ctx the invocation context, used only for the log message's method name + */ + private void safeCommitWithRetry(InvocationContext ctx) throws MorphiumDriverException { + // Snapshot the transaction context before the first commit attempt: PooledDriver's + // commitTransaction() clears it in a `finally` block unconditionally -- even when the + // commit command itself failed (see PooledDriver.java's commitTransaction/abortTransaction). + // Without re-installing it before each retry, safeCommit()'s own + // `morphium.getTransaction() == null` check (meant to tolerate "no DB operations + // occurred at all") would misinterpret "the driver already cleared the context after a + // FAILED commit attempt" as the exact same thing, silently converting a real commit + // failure into a reported success: for a genuinely transient error where the server + // actually did commit (code 251 after a failover, reply merely lost) that accidentally + // gives the right answer, but for a transient error where the server did NOT commit + // (code 112/WriteConflict at commit time -- nothing was persisted) the interceptor would + // return normally and fire AFTER_COMMIT while every write in the transaction is lost. + MorphiumTransactionContext txContext = morphium.getTransaction(); + int maxRetries = 3; + for (int attempt = 0; ; attempt++) { + if (attempt > 0) { + morphium.setTransaction(txContext); + } + try { + safeCommit(); + return; + } catch (MorphiumDriverException e) { + if (attempt >= maxRetries || !isTransientTransactionError(e)) { + throw e; + } + log.warnf("Transient error committing transaction for %s.%s (attempt %d/%d) — " + + "retrying the commit only, not the business method: %s", + ctx.getMethod().getDeclaringClass().getSimpleName(), + ctx.getMethod().getName(), + attempt + 1, maxRetries, + e.getMessage()); + long backoffMs = 50L * (1L << attempt); // exponential: 50, 100, 200ms + try { + Thread.sleep(Math.min(backoffMs, 1000L)); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw e; + } + } + } + } + + /** + * Aborts the current transaction if one exists, tolerating the case where + * no server-side transaction was started. + */ + private void safeAbort() { + if (morphium.getTransaction() == null) { + return; + } + try { + morphium.abortTransaction(); + } catch (MorphiumDriverException e) { + if (isNoServerTransaction(e)) { + log.debugf("No server-side transaction to abort (no DB operations occurred): %s", + e.getMessage()); + } else { + log.warnf("Could not abort transaction: %s", e.getMessage()); + } + } catch (Exception e) { + log.warnf("Could not abort transaction: %s", e.getMessage()); + } + } + + /** + * Returns {@code true} if {@code e} indicates there was no server-side transaction to + * commit/abort (e.g. every repository call inside the {@code @MorphiumTransactional} method + * ran against a driver/collection that never actually reached the server, such as + * {@code InMemDriver} in tests, or a method that made no writes at all). + * + *

    This is a best-effort heuristic based on matching known MongoDB server error message + * phrasings, not a documented MongoDB error code -- the driver layer (see + * {@code PooledDriver.commitTransaction}/{@code abortTransaction}) throws a plain + * {@code IllegalArgumentException} (not even a {@code MorphiumDriverException}) for the + * "no transaction context on this driver" case, which {@link #safeCommit}/{@link #safeAbort} + * already always short-circuit before reaching here via the {@code morphium.getTransaction() + * == null} check. This method instead covers the server-side case: a transaction context + * exists client-side, but the server never actually started a transaction for it (no + * operation was sent under it) -- MongoDB itself rejects the commit/abort command in that + * case, and the exact wording of that rejection is not part of any stable, code-based + * contract we could match against instead of a string. If a future MongoDB server version + * changes this wording, this check silently stops matching -- there is no more reliable + * signal available to fall back to without a documented error code for this specific case. + */ + static boolean isNoServerTransaction(MorphiumDriverException e) { + String msg = e.getMessage(); + if (msg == null) { + return false; + } + String lower = msg.toLowerCase(java.util.Locale.ROOT); + return lower.contains("cannot start a transaction") || lower.contains("no transaction started") + || lower.contains("no transaction is in progress") || lower.contains("no such transaction"); + } + + /** + * Executes the intercepted method without transaction wrapping but fires + * the same lifecycle events so that observers (outbox, cleanup, etc.) still work. + */ + private Object proceedWithEvents(InvocationContext ctx) throws Throwable { + try { + Object result = ctx.proceed(); + beforeCommit.fire(new MorphiumTransactionEvent(Phase.BEFORE_COMMIT)); + afterCommit.fire(new MorphiumTransactionEvent(Phase.AFTER_COMMIT)); + return result; + } catch (Throwable t) { + if (t instanceof Exception e) { + afterRollback.fire(new MorphiumTransactionEvent(Phase.AFTER_ROLLBACK, e)); + } + throw t; + } + } + + /** + * Returns {@code true} for a {@link CompletionStage} return type, or Mutiny's + * {@code io.smallrye.mutiny.Uni} / {@code io.smallrye.mutiny.Multi} by class name (Mutiny is + * not a compile-time dependency of this module, so it cannot be referenced directly — + * checking the name still correctly detects it whether or not Mutiny happens to be on the + * runtime classpath). + */ + static boolean isAsyncReturnType(Class returnType) { + return CompletionStage.class.isAssignableFrom(returnType) + || "io.smallrye.mutiny.Uni".equals(returnType.getName()) + || "io.smallrye.mutiny.Multi".equals(returnType.getName()); + } + + /** + * Returns {@code true} if the exception (or any cause in its chain) is a + * transient MongoDB transaction error that is safe to retry: + *

      + *
    • 112 — WriteConflict (includes transaction eviction under load)
    • + *
    • 251 — NoSuchTransaction (transaction expired on the server)
    • + *
    + */ + static boolean isTransientTransactionError(Exception e) { + if (!(e instanceof MorphiumDriverException mde)) { + // Check cause chain — Morphium exceptions are often wrapped + Throwable cause = e.getCause(); + while (cause != null) { + if (cause instanceof MorphiumDriverException mdeCause) { + return isTransientMongoCode(mdeCause); + } + cause = cause.getCause(); + } + return false; + } + return isTransientMongoCode(mde); + } + + private static boolean isTransientMongoCode(MorphiumDriverException e) { + if (e.getMongoCode() instanceof Number mc) { + int code = mc.intValue(); + return code == 112 // WriteConflict (incl. transaction eviction) + || code == 251; // NoSuchTransaction + } + return false; + } +} diff --git a/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/transaction/MorphiumTxPhase.java b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/transaction/MorphiumTxPhase.java new file mode 100644 index 000000000..32833a66f --- /dev/null +++ b/quarkus-morphium/runtime/src/main/java/de/caluga/morphium/quarkus/transaction/MorphiumTxPhase.java @@ -0,0 +1,37 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.transaction; + +import jakarta.inject.Qualifier; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * CDI qualifier used to observe {@link MorphiumTransactionEvent}s for a + * specific {@link MorphiumTransactionEvent.Phase}. + * + *
    {@code
    + * void onCommit(@Observes @MorphiumTxPhase(AFTER_COMMIT) MorphiumTransactionEvent e) { ... }
    + * }
    + */ +@Qualifier +@Target({ElementType.PARAMETER, ElementType.FIELD, ElementType.METHOD}) +@Retention(RetentionPolicy.RUNTIME) +public @interface MorphiumTxPhase { + MorphiumTransactionEvent.Phase value(); +} diff --git a/quarkus-morphium/runtime/src/main/resources-filtered/META-INF/morphium-version.properties b/quarkus-morphium/runtime/src/main/resources-filtered/META-INF/morphium-version.properties new file mode 100644 index 000000000..81de04a76 --- /dev/null +++ b/quarkus-morphium/runtime/src/main/resources-filtered/META-INF/morphium-version.properties @@ -0,0 +1,3 @@ +extension.version=${project.version} +morphium.version=${project.version} +jakarta.data.version=${jakarta.data.version} diff --git a/quarkus-morphium/runtime/src/main/resources/META-INF/native-image/de.caluga/quarkus-morphium/native-image.properties b/quarkus-morphium/runtime/src/main/resources/META-INF/native-image/de.caluga/quarkus-morphium/native-image.properties new file mode 100644 index 000000000..a385852c3 --- /dev/null +++ b/quarkus-morphium/runtime/src/main/resources/META-INF/native-image/de.caluga/quarkus-morphium/native-image.properties @@ -0,0 +1,4 @@ +# JOL (Java Object Layout) is used by Morphium's InMemoryDriver for memory size estimation. +# org.openjdk.jol.vm.sa.ServiceabilityAgentSupport references sun.management.VMManagement +# which requires a module export for the native-image builder JVM. +Args = -J--add-exports=java.management/sun.management=ALL-UNNAMED diff --git a/quarkus-morphium/runtime/src/main/resources/META-INF/quarkus-extension.yaml b/quarkus-morphium/runtime/src/main/resources/META-INF/quarkus-extension.yaml new file mode 100644 index 000000000..d3f38c1f1 --- /dev/null +++ b/quarkus-morphium/runtime/src/main/resources/META-INF/quarkus-extension.yaml @@ -0,0 +1,18 @@ +name: "Morphium MongoDB ORM" +description: > + Integrates the Morphium MongoDB ORM into Quarkus via a CDI producer, + type-safe configuration, declarative @MorphiumTransactional transactions, + and GraalVM native reflection registration for all @Entity and @Embedded classes. +metadata: + keywords: + - "mongodb" + - "morphium" + - "orm" + - "nosql" + - "devservices" + guide: "https://github.com/sboesebeck/morphium/blob/develop/quarkus-morphium/docs/modules/ROOT/pages/index.adoc" + categories: + - "data" + status: "preview" + config: + - "quarkus.morphium.*" diff --git a/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/MorphiumProducerConfigValidationTest.java b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/MorphiumProducerConfigValidationTest.java new file mode 100644 index 000000000..15724a10c --- /dev/null +++ b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/MorphiumProducerConfigValidationTest.java @@ -0,0 +1,91 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Regression tests for {@link MorphiumProducer#validateCredentialsPresence} and + * {@link MorphiumProducer#toIntGlobalCacheValidTime}. + * + *

    Previously, {@code buildMorphium()} silently connected unauthenticated when only one of + * {@code quarkus.morphium.username}/{@code password} was set, and silently overflowed + * {@code quarkus.morphium.cache.global-valid-time} values above ~24.8 days via a direct + * {@code (int)} cast. Both are should-fix findings from Stephan Boesebeck's review on PR #267 + * (sboesebeck/morphium). + */ +@DisplayName("MorphiumProducer — config validation") +class MorphiumProducerConfigValidationTest { + + @Test + @DisplayName("validateCredentialsPresence: both present is valid") + void bothCredentialsPresent_isValid() { + MorphiumProducer.validateCredentialsPresence(true, true); + // no exception -- success + } + + @Test + @DisplayName("validateCredentialsPresence: both absent is valid") + void bothCredentialsAbsent_isValid() { + MorphiumProducer.validateCredentialsPresence(false, false); + // no exception -- success + } + + @Test + @DisplayName("validateCredentialsPresence: username without password throws") + void usernameWithoutPassword_throws() { + assertThatThrownBy(() -> MorphiumProducer.validateCredentialsPresence(true, false)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("quarkus.morphium.password") + .hasMessageContaining("quarkus.morphium.username"); + } + + @Test + @DisplayName("validateCredentialsPresence: password without username throws") + void passwordWithoutUsername_throws() { + assertThatThrownBy(() -> MorphiumProducer.validateCredentialsPresence(false, true)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("quarkus.morphium.username") + .hasMessageContaining("quarkus.morphium.password"); + } + + @Test + @DisplayName("toIntGlobalCacheValidTime: default (60000ms) narrows without loss") + void defaultValue_narrowsCorrectly() { + assertThat(MorphiumProducer.toIntGlobalCacheValidTime(60000L)).isEqualTo(60000); + } + + @Test + @DisplayName("toIntGlobalCacheValidTime: Integer.MAX_VALUE itself is still accepted") + void maxIntValue_isAccepted() { + assertThat(MorphiumProducer.toIntGlobalCacheValidTime((long) Integer.MAX_VALUE)) + .isEqualTo(Integer.MAX_VALUE); + } + + @Test + @DisplayName("toIntGlobalCacheValidTime: a 30-day value (which would silently overflow via a raw cast) throws instead") + void thirtyDayValue_throwsInsteadOfOverflowing() { + long thirtyDaysMs = 30L * 24 * 60 * 60 * 1000; // 2_592_000_000 -- overflows int + assertThatThrownBy(() -> MorphiumProducer.toIntGlobalCacheValidTime(thirtyDaysMs)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("global-valid-time") + .hasMessageContaining(String.valueOf(thirtyDaysMs)); + } +} diff --git a/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/MorphiumProducerIndexCheckModeTest.java b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/MorphiumProducerIndexCheckModeTest.java new file mode 100644 index 000000000..ffe39b125 --- /dev/null +++ b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/MorphiumProducerIndexCheckModeTest.java @@ -0,0 +1,110 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus; + +import de.caluga.morphium.MorphiumConfig; +import de.caluga.morphium.config.CollectionCheckSettings; +import io.quarkus.runtime.ImageMode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Covers {@link MorphiumProducer#applyIndexCheckMode} -- specifically the + * {@code CREATE_ON_WRITE_NEW_COL} branch, which needs both the index and the capped check + * disabled when running as a native image. + * + *

    Background: {@code setAutoIndexAndCappedCreationOnWrite(true)} sets BOTH checks to + * {@code CREATE_ON_WRITE_NEW_COL}, and {@code Morphium.initializeAndConnect()} calls + * {@code checkCapped()} unconditionally (unlike {@code checkIndices()}, which is gated to the + * two startup modes). A live ClassGraph scan from there cannot work in a native image. + */ +@DisplayName("MorphiumProducer.applyIndexCheckMode") +class MorphiumProducerIndexCheckModeTest { + + @Test + @DisplayName("CREATE_ON_WRITE_NEW_COL in a native image disables BOTH the index and the capped check") + void createOnWriteNewCol_native_disablesIndexAndCappedCheck() { + MorphiumConfig cfg = new MorphiumConfig(); + + MorphiumProducer.applyIndexCheckMode(cfg, + MorphiumRuntimeConfig.IndexCheckMode.CREATE_ON_WRITE_NEW_COL, + ImageMode.NATIVE_RUN); + + assertThat(cfg.collectionCheckSettings().getIndexCheck()) + .as("index check must be off: checkIndices() would scan the classpath") + .isEqualTo(CollectionCheckSettings.IndexCheck.NO_CHECK); + assertThat(cfg.collectionCheckSettings().getCappedCheck()) + .as("capped check must be off too -- checkCapped() runs UNCONDITIONALLY in " + + "Morphium.initializeAndConnect(), so disabling only the index check " + + "would leave the ClassGraph scan reachable") + .isEqualTo(CollectionCheckSettings.CappedCheck.NO_CHECK); + } + + @Test + @DisplayName("CREATE_ON_WRITE_NEW_COL on the JVM keeps create-on-write active for both checks") + void createOnWriteNewCol_jvm_keepsCreateOnWriteBehaviour() { + MorphiumConfig cfg = new MorphiumConfig(); + + MorphiumProducer.applyIndexCheckMode(cfg, + MorphiumRuntimeConfig.IndexCheckMode.CREATE_ON_WRITE_NEW_COL, + ImageMode.JVM); + + // On the JVM the scan is merely a startup cost, not fatal, so the mode must keep doing + // what the user asked for: create indexes/capped collections on first write. + assertThat(cfg.collectionCheckSettings().getIndexCheck()) + .isEqualTo(CollectionCheckSettings.IndexCheck.CREATE_ON_WRITE_NEW_COL); + assertThat(cfg.collectionCheckSettings().getCappedCheck()) + .isEqualTo(CollectionCheckSettings.CappedCheck.CREATE_ON_WRITE_NEW_COL); + } + + @Test + @DisplayName("CREATE_ON_STARTUP defers to Producer.ensureIndices() by disabling the internal check") + void createOnStartup_disablesInternalIndexCheck() { + MorphiumConfig cfg = new MorphiumConfig(); + + MorphiumProducer.applyIndexCheckMode(cfg, + MorphiumRuntimeConfig.IndexCheckMode.CREATE_ON_STARTUP, ImageMode.JVM); + + assertThat(cfg.collectionCheckSettings().getIndexCheck()) + .isEqualTo(CollectionCheckSettings.IndexCheck.NO_CHECK); + } + + @Test + @DisplayName("NO_CHECK disables the index check") + void noCheck_disablesIndexCheck() { + MorphiumConfig cfg = new MorphiumConfig(); + + MorphiumProducer.applyIndexCheckMode(cfg, + MorphiumRuntimeConfig.IndexCheckMode.NO_CHECK, ImageMode.JVM); + + assertThat(cfg.collectionCheckSettings().getIndexCheck()) + .isEqualTo(CollectionCheckSettings.IndexCheck.NO_CHECK); + } + + @Test + @DisplayName("WARN_ON_STARTUP is passed through unchanged") + void warnOnStartup_isPassedThrough() { + MorphiumConfig cfg = new MorphiumConfig(); + + MorphiumProducer.applyIndexCheckMode(cfg, + MorphiumRuntimeConfig.IndexCheckMode.WARN_ON_STARTUP, ImageMode.JVM); + + assertThat(cfg.collectionCheckSettings().getIndexCheck()) + .isEqualTo(CollectionCheckSettings.IndexCheck.WARN_ON_STARTUP); + } +} diff --git a/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/MorphiumProducerReadPreferenceTest.java b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/MorphiumProducerReadPreferenceTest.java new file mode 100644 index 000000000..fcf453bd7 --- /dev/null +++ b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/MorphiumProducerReadPreferenceTest.java @@ -0,0 +1,88 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus; + +import de.caluga.morphium.driver.ReadPreference; +import de.caluga.morphium.driver.ReadPreferenceType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Regression tests for {@link MorphiumProducer#parseReadPreference}. + * + *

    Previously, {@code buildMorphium()} called + * {@code cfg.driverSettings().setDefaultReadPreferenceType(config.readPreference())}, which sets a + * dead {@code defaultReadPreferenceType} string field that nothing in morphium-core reads. The + * actual read path uses {@code DriverSettings.getDefaultReadPreference()}, which defaults to + * {@code ReadPreference.nearest()} regardless of what + * {@code quarkus.morphium.read-preference} was configured to. Every app on a replica set + * therefore read with NEAREST instead of the documented default {@code primary} (stale reads out + * of the box), and no value of the setting changed that. Fixed by parsing the string into a real + * {@link ReadPreference} and calling {@code setDefaultReadPreference(ReadPreference)} instead. + */ +class MorphiumProducerReadPreferenceTest { + + @Test + @DisplayName("\"primary\" parses to ReadPreference.primary()") + void primary() { + assertThat(MorphiumProducer.parseReadPreference("primary").getType()) + .isEqualTo(ReadPreferenceType.PRIMARY); + } + + @Test + @DisplayName("\"primaryPreferred\" parses to ReadPreference.primaryPreferred()") + void primaryPreferred() { + assertThat(MorphiumProducer.parseReadPreference("primaryPreferred").getType()) + .isEqualTo(ReadPreferenceType.PRIMARY_PREFERRED); + } + + @Test + @DisplayName("\"secondary\" parses to ReadPreference.secondary()") + void secondary() { + assertThat(MorphiumProducer.parseReadPreference("secondary").getType()) + .isEqualTo(ReadPreferenceType.SECONDARY); + } + + @Test + @DisplayName("\"secondaryPreferred\" parses to ReadPreference.secondaryPreferred()") + void secondaryPreferred() { + assertThat(MorphiumProducer.parseReadPreference("secondaryPreferred").getType()) + .isEqualTo(ReadPreferenceType.SECONDARY_PREFERRED); + } + + @Test + @DisplayName("\"nearest\" parses to ReadPreference.nearest()") + void nearest() { + assertThat(MorphiumProducer.parseReadPreference("nearest").getType()) + .isEqualTo(ReadPreferenceType.NEAREST); + } + + @Test + @DisplayName("Case-insensitive matching") + void caseInsensitive() { + assertThat(MorphiumProducer.parseReadPreference("PRIMARY").getType()) + .isEqualTo(ReadPreferenceType.PRIMARY); + } + + @Test + @DisplayName("Unrecognized value falls back to primary(), matching the documented default") + void unrecognizedValueFallsBackToPrimary() { + assertThat(MorphiumProducer.parseReadPreference("bogus").getType()) + .isEqualTo(ReadPreferenceType.PRIMARY); + } +} diff --git a/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/MorphiumVersionTest.java b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/MorphiumVersionTest.java new file mode 100644 index 000000000..78f264acd --- /dev/null +++ b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/MorphiumVersionTest.java @@ -0,0 +1,69 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that {@code META-INF/morphium-version.properties} is correctly populated by + * Maven resource filtering at build time, and that {@link MorphiumVersion} reads it back. + * + *

    Regression test: {@code morphium.version} previously referenced an undefined + * {@code ${morphium.version}} Maven property (no such property exists in the reactor), + * so filtering left the literal placeholder string in the built JAR instead of the + * actual version — {@link #morphiumVersion()} would silently report a wrong value. + * Since this module is lockstep-versioned with Morphium core, the property now reads + * {@code ${project.version}}, the same expression already used for + * {@code extension.version}. + */ +class MorphiumVersionTest { + + @Test + @DisplayName("extensionVersion() is populated, not \"unknown\" and not a literal placeholder") + void extensionVersionIsResolved() { + String version = MorphiumVersion.extensionVersion(); + assertThat(version).isNotEqualTo("unknown"); + assertThat(version).doesNotContain("${"); + } + + @Test + @DisplayName("morphiumVersion() is populated, not \"unknown\" and not a literal placeholder") + void morphiumVersionIsResolved() { + String version = MorphiumVersion.morphiumVersion(); + assertThat(version).isNotEqualTo("unknown"); + assertThat(version).doesNotContain("${"); + } + + @Test + @DisplayName("morphiumVersion() and extensionVersion() are identical (lockstep versioning)") + void morphiumVersionMatchesExtensionVersion() { + // Both properties resolve from ${project.version} on this reactor -- lockstep + // versioning means they must always be the same value. + assertThat(MorphiumVersion.morphiumVersion()) + .isEqualTo(MorphiumVersion.extensionVersion()); + } + + @Test + @DisplayName("jakartaDataVersion() is populated, not \"unknown\" and not a literal placeholder") + void jakartaDataVersionIsResolved() { + String version = MorphiumVersion.jakartaDataVersion(); + assertThat(version).isNotEqualTo("unknown"); + assertThat(version).doesNotContain("${"); + } +} diff --git a/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/health/MorphiumStartupCheckTest.java b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/health/MorphiumStartupCheckTest.java new file mode 100644 index 000000000..ecde097b3 --- /dev/null +++ b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/health/MorphiumStartupCheckTest.java @@ -0,0 +1,57 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.health; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link MorphiumStartupCheck#isEverConnected}, the SRV-discovery-tolerant + * startup check logic. + * + *

    These tests call the production method directly (it is package-private specifically for + * this reason — see its Javadoc) rather than a duplicated copy of its formula, so a future edit + * that breaks the logic (e.g. flipping {@code ||} to {@code &&}) is guaranteed to be caught here. + */ +@DisplayName("MorphiumStartupCheck — SRV discovery tolerance") +class MorphiumStartupCheckTest { + + @Test + @DisplayName("DOWN when no connections opened and driver not connected (SRV discovery in progress)") + void downDuringSrvDiscovery() { + assertThat(MorphiumStartupCheck.isEverConnected(0.0, false)).isFalse(); + } + + @Test + @DisplayName("UP when connections opened but driver reports not connected (hosts map empty)") + void upWhenConnectionsOpenedButHostsMapEmpty() { + assertThat(MorphiumStartupCheck.isEverConnected(5.0, false)).isTrue(); + } + + @Test + @DisplayName("UP when driver reports connected (normal operation)") + void upWhenDriverConnected() { + assertThat(MorphiumStartupCheck.isEverConnected(10.0, true)).isTrue(); + } + + @Test + @DisplayName("UP when driver connected but no connections opened (InMemoryDriver)") + void upWhenDriverConnectedNoConnectionsOpened() { + assertThat(MorphiumStartupCheck.isEverConnected(0.0, true)).isTrue(); + } +} diff --git a/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/json/MorphiumIdJacksonModuleTest.java b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/json/MorphiumIdJacksonModuleTest.java new file mode 100644 index 000000000..19e691be3 --- /dev/null +++ b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/json/MorphiumIdJacksonModuleTest.java @@ -0,0 +1,107 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.json; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import de.caluga.morphium.driver.MorphiumId; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Verifies that {@link MorphiumIdJacksonModule} serializes {@link MorphiumId} + * as a flat hex string (not the internal bean struct) and parses it back. + */ +@DisplayName("MorphiumId Jackson (de)serialization") +class MorphiumIdJacksonModuleTest { + + private ObjectMapper mapperWithModule() { + ObjectMapper mapper = new ObjectMapper(); + new MorphiumIdJacksonModule().customize(mapper); + return mapper; + } + + /** A minimal entity-shaped DTO with an {@code @Id}-style MorphiumId field. */ + public static class Doc { + public MorphiumId id; + public String name; + } + + @Test + @DisplayName("entity serializes id as \"\", not the {pid,counter,...} struct") + void serializesAsHexString() throws Exception { + MorphiumId id = new MorphiumId(); + Doc doc = new Doc(); + doc.id = id; + doc.name = "widget"; + + String json = mapperWithModule().writeValueAsString(doc); + + assertThat(json).contains("\"id\":\"" + id + "\""); + // None of the internal getters must leak into the JSON. + assertThat(json).doesNotContain("pid"); + assertThat(json).doesNotContain("counter"); + assertThat(json).doesNotContain("machineId"); + assertThat(json).doesNotContain("bytes"); + } + + @Test + @DisplayName("a bare MorphiumId serializes to a JSON string literal") + void bareIdSerializesToStringLiteral() throws Exception { + MorphiumId id = new MorphiumId(); + String json = mapperWithModule().writeValueAsString(id); + assertThat(json).isEqualTo("\"" + id + "\""); + } + + @Test + @DisplayName("\"\" deserializes back into an equal MorphiumId") + void deserializesFromHexString() throws Exception { + MorphiumId id = new MorphiumId(); + ObjectMapper mapper = mapperWithModule(); + + String json = "{\"id\":\"" + id + "\",\"name\":\"widget\"}"; + Doc parsed = mapper.readValue(json, Doc.class); + + assertThat(parsed.id).isEqualTo(id); + assertThat(parsed.name).isEqualTo("widget"); + } + + @Test + @DisplayName("round-trips entity -> JSON -> entity preserving id identity") + void roundTrips() throws Exception { + MorphiumId id = new MorphiumId(); + Doc doc = new Doc(); + doc.id = id; + doc.name = "round"; + + ObjectMapper mapper = mapperWithModule(); + Doc back = mapper.readValue(mapper.writeValueAsString(doc), Doc.class); + + assertThat(back.id).isEqualTo(id); + } + + @Test + @DisplayName("null and blank id strings deserialize to null") + void nullAndBlankDeserializeToNull() throws Exception { + ObjectMapper mapper = mapperWithModule(); + + assertThat(mapper.readValue("{\"id\":null}", Doc.class).id).isNull(); + assertThat(mapper.readValue("{\"id\":\"\"}", Doc.class).id).isNull(); + } +} diff --git a/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/json/MorphiumIdJsonbAdapterTest.java b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/json/MorphiumIdJsonbAdapterTest.java new file mode 100644 index 000000000..48bf54fe1 --- /dev/null +++ b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/json/MorphiumIdJsonbAdapterTest.java @@ -0,0 +1,94 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.json; + +import static org.assertj.core.api.Assertions.assertThat; + +import de.caluga.morphium.driver.MorphiumId; + +import jakarta.json.bind.Jsonb; +import jakarta.json.bind.JsonbBuilder; +import jakarta.json.bind.JsonbConfig; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * Verifies that {@link MorphiumIdJsonbModule} / {@link MorphiumIdJsonbAdapter} + * map {@link MorphiumId} to and from a flat hex string under JSON-B. + */ +@DisplayName("MorphiumId JSON-B (de)serialization") +class MorphiumIdJsonbAdapterTest { + + private Jsonb jsonbWithAdapter() { + JsonbConfig config = new JsonbConfig(); + new MorphiumIdJsonbModule().customize(config); + return JsonbBuilder.create(config); + } + + /** A minimal entity-shaped DTO with an {@code @Id}-style MorphiumId field. */ + public static class Doc { + public MorphiumId id; + public String name; + } + + @Test + @DisplayName("entity serializes id as \"\", not the {pid,counter,...} struct") + void serializesAsHexString() throws Exception { + MorphiumId id = new MorphiumId(); + Doc doc = new Doc(); + doc.id = id; + doc.name = "widget"; + + try (Jsonb jsonb = jsonbWithAdapter()) { + String json = jsonb.toJson(doc); + + assertThat(json).contains("\"id\":\"" + id + "\""); + assertThat(json).doesNotContain("pid"); + assertThat(json).doesNotContain("counter"); + assertThat(json).doesNotContain("machineId"); + assertThat(json).doesNotContain("bytes"); + } + } + + @Test + @DisplayName("\"\" deserializes back into an equal MorphiumId") + void deserializesFromHexString() throws Exception { + MorphiumId id = new MorphiumId(); + + try (Jsonb jsonb = jsonbWithAdapter()) { + String json = "{\"id\":\"" + id + "\",\"name\":\"widget\"}"; + Doc parsed = jsonb.fromJson(json, Doc.class); + + assertThat(parsed.id).isEqualTo(id); + assertThat(parsed.name).isEqualTo("widget"); + } + } + + @Test + @DisplayName("round-trips entity -> JSON -> entity preserving id identity") + void roundTrips() throws Exception { + MorphiumId id = new MorphiumId(); + Doc doc = new Doc(); + doc.id = id; + doc.name = "round"; + + try (Jsonb jsonb = jsonbWithAdapter()) { + Doc back = jsonb.fromJson(jsonb.toJson(doc), Doc.class); + assertThat(back.id).isEqualTo(id); + } + } +} diff --git a/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/migration/MorphiumMigrationRunnerOrderingTest.java b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/migration/MorphiumMigrationRunnerOrderingTest.java new file mode 100644 index 000000000..3fca7c19e --- /dev/null +++ b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/migration/MorphiumMigrationRunnerOrderingTest.java @@ -0,0 +1,91 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.migration; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Regression tests for {@link MorphiumMigrationRunner#compareByOrder}. + * + *

    Previously, migrations were sorted with {@code Comparator.comparing(MigrationInfo::order)}, + * a plain lexicographic string comparison. {@code order()} is a {@code String}, so "10" sorts + * BEFORE "2" lexicographically -- this codebase's own tests never caught it because every + * existing test migration happens to use a zero-padded, equal-width order string ("001", "002", + * "999"). A real project with more than 9 migrations and unpadded order values would see them + * silently run out of order. + */ +@DisplayName("MorphiumMigrationRunner — migration ordering (should-fix #9)") +class MorphiumMigrationRunnerOrderingTest { + + private static MorphiumMigrationRunner.MigrationInfo info(String order) { + return new MorphiumMigrationRunner.MigrationInfo( + "id-" + order, order, "test", "TestClass", Object.class, null, null); + } + + @Test + @DisplayName("numeric order values sort numerically, not lexicographically: \"2\" before \"10\"") + void numericOrderValues_sortNumerically() { + List migrations = new ArrayList<>(List.of( + info("10"), info("2"), info("1"))); + + migrations.sort(MorphiumMigrationRunner::compareByOrder); + + assertThat(migrations).extracting(MorphiumMigrationRunner.MigrationInfo::order) + .containsExactly("1", "2", "10"); + } + + @Test + @DisplayName("zero-padded order values (the existing test-suite convention) still sort correctly") + void zeroPaddedOrderValues_stillSortCorrectly() { + List migrations = new ArrayList<>(List.of( + info("999"), info("001"), info("002"))); + + migrations.sort(MorphiumMigrationRunner::compareByOrder); + + assertThat(migrations).extracting(MorphiumMigrationRunner.MigrationInfo::order) + .containsExactly("001", "002", "999"); + } + + @Test + @DisplayName("non-numeric order values fall back to lexicographic comparison") + void nonNumericOrderValues_fallBackToLexicographic() { + List migrations = new ArrayList<>(List.of( + info("2024-06-01"), info("2024-01-01"), info("2024-03-01"))); + + migrations.sort(MorphiumMigrationRunner::compareByOrder); + + assertThat(migrations).extracting(MorphiumMigrationRunner.MigrationInfo::order) + .containsExactly("2024-01-01", "2024-03-01", "2024-06-01"); + } + + @Test + @DisplayName("a mix of numeric and non-numeric order values does not throw") + void mixedNumericAndNonNumeric_doesNotThrow() { + List migrations = new ArrayList<>(List.of( + info("10"), info("abc"))); + + // Must not throw NumberFormatException -- just document it doesn't crash; + // mixed conventions within one project are a user error, not something to optimize for. + migrations.sort(MorphiumMigrationRunner::compareByOrder); + assertThat(migrations).hasSize(2); + } +} diff --git a/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/transaction/MorphiumTransactionalInterceptorCommitRetryTest.java b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/transaction/MorphiumTransactionalInterceptorCommitRetryTest.java new file mode 100644 index 000000000..c7d43f340 --- /dev/null +++ b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/transaction/MorphiumTransactionalInterceptorCommitRetryTest.java @@ -0,0 +1,211 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.transaction; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.driver.MorphiumDriver; +import de.caluga.morphium.driver.MorphiumDriverException; +import de.caluga.morphium.driver.MorphiumTransactionContext; +import jakarta.enterprise.event.Event; +import jakarta.interceptor.InvocationContext; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Regression test for the commit-retry data-loss bug found by Stephan Boesebeck's re-review + * (2026-08-06, item A) on top of the blocker #5 fix. + * + *

    {@code PooledDriver.commitTransaction()} clears the transaction context in a {@code finally} + * block unconditionally -- even when the commit command itself failed. Without + * {@code safeCommitWithRetry()} re-installing the saved context before each retry attempt, + * {@code safeCommit()}'s own {@code morphium.getTransaction() == null} check (meant to tolerate + * "no DB operations occurred at all") misinterprets "the driver already cleared the context + * after a FAILED commit attempt" as exactly that, and returns normally -- silently converting a + * real commit failure (nothing persisted) into a reported success. + * + *

    This test builds a fake {@link MorphiumDriver} whose {@code commitTransaction()} mimics + * {@code PooledDriver}'s exact behavior: it always clears the transaction context, even when it + * throws. The first call throws a transient (code 112) {@link MorphiumDriverException}; the + * second call (the retry) succeeds. If {@code safeCommitWithRetry()} does not re-install the + * saved context before the retry, {@code safeCommit()} would see a null context on the second + * attempt and short-circuit as "success" WITHOUT actually calling {@code commitTransaction()} + * again -- which this test also verifies against directly (via the invocation count on the fake + * driver), not just the interceptor's return value. + */ +@DisplayName("MorphiumTransactionalInterceptor — commit-retry context preservation") +class MorphiumTransactionalInterceptorCommitRetryTest { + + private Morphium morphium; + private MorphiumDriver driver; + private MorphiumTransactionalInterceptor interceptor; + + /** Mimics PooledDriver's real (buggy-if-not-handled) behavior: the transaction context + * ThreadLocal is cleared unconditionally by commitTransaction(), success or failure. */ + private MorphiumTransactionContext transactionContext; + + @BeforeEach + void setUp() { + morphium = mock(Morphium.class); + driver = mock(MorphiumDriver.class); + // Must start out null: aroundInvoke() checks morphium.getTransaction() != null very + // early for REQUIRED-propagation ("join an already active transaction"). If this were + // pre-seeded with a mock here, aroundInvoke() would take that join-existing-transaction + // branch and return ctx.proceed() directly, calling neither startTransaction() nor + // commitTransaction() at all -- the doAnswer stub for morphium.startTransaction() below + // is what assigns a fresh mock to this field once aroundInvoke() actually starts its own + // transaction. + transactionContext = null; + + when(morphium.getDriver()).thenReturn(driver); + try { + when(driver.isCosmosDB()).thenReturn(false); + } catch (Exception e) { + throw new RuntimeException(e); + } + + // getTransaction()/setTransaction() delegate to a single mutable field, exactly like + // the real Morphium.getTransaction()/setTransaction() delegate to the driver's + // transaction-context ThreadLocal. + when(morphium.getTransaction()).thenAnswer(inv -> transactionContext); + doAnswer(inv -> { + transactionContext = inv.getArgument(0); + return null; + }).when(morphium).setTransaction(any()); + + doAnswer(inv -> { + transactionContext = mock(MorphiumTransactionContext.class); + return null; + }).when(morphium).startTransaction(); + + interceptor = new MorphiumTransactionalInterceptor(); + interceptor.morphium = morphium; + interceptor.beforeCommit = noopEvent(); + interceptor.afterCommit = noopEvent(); + interceptor.afterRollback = noopEvent(); + } + + @SuppressWarnings("unchecked") + private static Event noopEvent() { + return mock(Event.class); + } + + private InvocationContext fakeInvocationContext(Object returnValue) throws Exception { + InvocationContext ctx = mock(InvocationContext.class); + Method dummyMethod = String.class.getMethod("trim"); + when(ctx.getMethod()).thenReturn(dummyMethod); + when(ctx.proceed()).thenReturn(returnValue); + return ctx; + } + + @Test + @DisplayName("code 112 (WriteConflict) at commit time is retried by re-installing the saved context, not silently treated as success") + void transientCommitFailure_retriesWithRestoredContext_notSilentSuccess() throws Throwable { + AtomicInteger commitCallCount = new AtomicInteger(0); + + // Mimic PooledDriver.commitTransaction(): finally { clearTransactionContext(); } runs + // unconditionally, even when the commit command itself failed. + doAnswer(inv -> { + int call = commitCallCount.incrementAndGet(); + try { + if (call == 1) { + MorphiumDriverException e = new MorphiumDriverException("WriteConflict at commit"); + e.setMongoCode(112); + throw e; + } + // call == 2 (the retry): succeeds + return null; + } finally { + transactionContext = null; // PooledDriver's unconditional finally-block clear + } + }).when(morphium).commitTransaction(); + + InvocationContext ctx = fakeInvocationContext("business-result"); + // Deliberately NOT calling morphium.startTransaction() here first: aroundInvoke() itself + // checks morphium.getTransaction() != null for REQUIRED-propagation (join an already + // active transaction) before doing anything else. Pre-seeding a context would make it + // take that join-existing-transaction branch and return ctx.proceed() directly, calling + // neither startTransaction() nor commitTransaction() at all -- exactly the failure mode + // that produced a false "success" here on the first attempt at writing this test + // (commitCallCount stayed at 0, not 2, because aroundInvoke() never got past the + // REQUIRED-propagation check to its own transaction-start/commit logic). + Object result = invokeAroundInvoke(ctx); + + assertThat(result).as("business method result must be returned on eventual success") + .isEqualTo("business-result"); + assertThat(commitCallCount.get()) + .as("commitTransaction() must actually be called twice: once (fails), once more (the retry) -- " + + "if safeCommit() short-circuited on the second attempt seeing a null context, " + + "this would be 1, not 2") + .isEqualTo(2); + } + + @Test + @DisplayName("code 11000 (DuplicateKey) at commit time is NOT transient -- must not be retried and must propagate") + void nonTransientCommitFailure_isNotRetried_andPropagates() throws Exception { + AtomicInteger commitCallCount = new AtomicInteger(0); + + // Same fake-driver pattern as the transient case above, but the commit failure is a + // non-transient MongoDB error (11000 / DuplicateKey is not in isTransientTransactionError()'s + // allow-list of 112/251). safeCommitWithRetry() must therefore rethrow immediately after + // the FIRST attempt instead of retrying, and aroundInvoke() must propagate that exception + // out to the caller (after firing AFTER_ROLLBACK, not AFTER_COMMIT). + doAnswer(inv -> { + commitCallCount.incrementAndGet(); + try { + MorphiumDriverException e = new MorphiumDriverException("E11000 duplicate key error"); + e.setMongoCode(11000); + throw e; + } finally { + transactionContext = null; // PooledDriver's unconditional finally-block clear + } + }).when(morphium).commitTransaction(); + + InvocationContext ctx = fakeInvocationContext("business-result"); + + assertThatThrownBy(() -> invokeAroundInvoke(ctx)) + .as("a non-transient commit error must propagate out of aroundInvoke(), not be swallowed") + .isInstanceOf(MorphiumDriverException.class) + .satisfies(t -> assertThat(((MorphiumDriverException) t).getMongoCode()).isEqualTo(11000)); + + assertThat(commitCallCount.get()) + .as("commitTransaction() must be called exactly once: a non-transient error must not be retried") + .isEqualTo(1); + } + + /** Invokes the package-private aroundInvoke() via reflection (it's not public API). */ + private Object invokeAroundInvoke(InvocationContext ctx) throws Throwable { + try { + Method m = MorphiumTransactionalInterceptor.class.getDeclaredMethod("aroundInvoke", InvocationContext.class); + m.setAccessible(true); + return m.invoke(interceptor, ctx); + } catch (java.lang.reflect.InvocationTargetException e) { + throw e.getCause(); + } + } +} diff --git a/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/transaction/MorphiumTransactionalInterceptorRetryTest.java b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/transaction/MorphiumTransactionalInterceptorRetryTest.java new file mode 100644 index 000000000..18e00ee35 --- /dev/null +++ b/quarkus-morphium/runtime/src/test/java/de/caluga/morphium/quarkus/transaction/MorphiumTransactionalInterceptorRetryTest.java @@ -0,0 +1,263 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.transaction; + +import de.caluga.morphium.driver.MorphiumDriverException; +import net.bytebuddy.ByteBuddy; +import net.bytebuddy.dynamic.loading.ClassLoadingStrategy; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for the transient-error detection logic in + * {@link MorphiumTransactionalInterceptor}. + * + *

    These tests exercise {@code isTransientTransactionError} directly — + * no Quarkus container or MongoDB connection is required. + */ +@DisplayName("MorphiumTransactionalInterceptor – transient error detection") +class MorphiumTransactionalInterceptorRetryTest { + + // ------------------------------------------------------------------------- + // Helper: build a MorphiumDriverException with a numeric mongo error code + // ------------------------------------------------------------------------- + + private static MorphiumDriverException exceptionWithCode(int code) { + MorphiumDriverException ex = new MorphiumDriverException("mongo error " + code); + ex.setMongoCode(code); + return ex; + } + + // ------------------------------------------------------------------------- + // Transient codes — should trigger retry + // ------------------------------------------------------------------------- + + @Test + @DisplayName("code 112 (WriteConflict) is transient") + void writeConflict_isTransient() { + assertThat(MorphiumTransactionalInterceptor.isTransientTransactionError(exceptionWithCode(112))) + .isTrue(); + } + + @Test + @DisplayName("code 251 (NoSuchTransaction) is transient") + void noSuchTransaction_isTransient() { + assertThat(MorphiumTransactionalInterceptor.isTransientTransactionError(exceptionWithCode(251))) + .isTrue(); + } + + // ------------------------------------------------------------------------- + // Non-transient codes — must NOT retry + // ------------------------------------------------------------------------- + + @Test + @DisplayName("code 11000 (DuplicateKey) is NOT transient") + void duplicateKey_isNotTransient() { + assertThat(MorphiumTransactionalInterceptor.isTransientTransactionError(exceptionWithCode(11000))) + .isFalse(); + } + + @Test + @DisplayName("code 0 is NOT transient") + void zeroCode_isNotTransient() { + assertThat(MorphiumTransactionalInterceptor.isTransientTransactionError(exceptionWithCode(0))) + .isFalse(); + } + + @Test + @DisplayName("MorphiumDriverException with no mongoCode set is NOT transient") + void noCodeSet_isNotTransient() { + MorphiumDriverException ex = new MorphiumDriverException("no code"); + assertThat(MorphiumTransactionalInterceptor.isTransientTransactionError(ex)) + .isFalse(); + } + + // ------------------------------------------------------------------------- + // Non-MorphiumDriverException — must NOT retry + // ------------------------------------------------------------------------- + + @Test + @DisplayName("plain RuntimeException is NOT transient") + void plainRuntimeException_isNotTransient() { + assertThat(MorphiumTransactionalInterceptor.isTransientTransactionError( + new RuntimeException("forced rollback"))) + .isFalse(); + } + + @Test + @DisplayName("IllegalStateException is NOT transient") + void illegalStateException_isNotTransient() { + assertThat(MorphiumTransactionalInterceptor.isTransientTransactionError( + new IllegalStateException("bad state"))) + .isFalse(); + } + + // ------------------------------------------------------------------------- + // Wrapped / cause-chain detection + // ------------------------------------------------------------------------- + + @Test + @DisplayName("WriteConflict (112) wrapped in RuntimeException IS detected as transient") + void writeConflict_wrappedInRuntimeException_isTransient() { + MorphiumDriverException cause = exceptionWithCode(112); + RuntimeException wrapper = new RuntimeException("wrapper", cause); + assertThat(MorphiumTransactionalInterceptor.isTransientTransactionError(wrapper)) + .isTrue(); + } + + @Test + @DisplayName("NoSuchTransaction (251) wrapped two levels deep IS detected as transient") + void noSuchTransaction_deeplyWrapped_isTransient() { + MorphiumDriverException root = exceptionWithCode(251); + RuntimeException mid = new RuntimeException("mid", root); + RuntimeException outer = new RuntimeException("outer", mid); + assertThat(MorphiumTransactionalInterceptor.isTransientTransactionError(outer)) + .isTrue(); + } + + @Test + @DisplayName("DuplicateKey (11000) wrapped in RuntimeException is NOT transient") + void duplicateKey_wrappedInRuntimeException_isNotTransient() { + MorphiumDriverException cause = exceptionWithCode(11000); + RuntimeException wrapper = new RuntimeException("wrapper", cause); + assertThat(MorphiumTransactionalInterceptor.isTransientTransactionError(wrapper)) + .isFalse(); + } + + // ------------------------------------------------------------------------- + // mongoCode as Long (Number subtype other than Integer) + // ------------------------------------------------------------------------- + + @Test + @DisplayName("code 112 stored as Long is still detected as transient") + void writeConflict_asLong_isTransient() { + MorphiumDriverException ex = new MorphiumDriverException("write conflict via long"); + ex.setMongoCode(112L); + assertThat(MorphiumTransactionalInterceptor.isTransientTransactionError(ex)) + .isTrue(); + } + + // ------------------------------------------------------------------------- + // isAsyncReturnType — merge blocker #8: async return types must be detected + // so aroundInvoke can fail fast instead of committing before the async work runs + // ------------------------------------------------------------------------- + + @Test + @DisplayName("CompletionStage is detected as an async return type") + void completionStage_isAsyncReturnType() { + assertThat(MorphiumTransactionalInterceptor.isAsyncReturnType( + java.util.concurrent.CompletionStage.class)).isTrue(); + } + + @Test + @DisplayName("CompletableFuture (a CompletionStage subtype) is detected as an async return type") + void completableFuture_isAsyncReturnType() { + assertThat(MorphiumTransactionalInterceptor.isAsyncReturnType( + java.util.concurrent.CompletableFuture.class)).isTrue(); + } + + @Test + @DisplayName("void is NOT an async return type") + void voidType_isNotAsyncReturnType() { + assertThat(MorphiumTransactionalInterceptor.isAsyncReturnType(void.class)).isFalse(); + } + + @Test + @DisplayName("a plain entity/DTO return type is NOT an async return type") + void plainReturnType_isNotAsyncReturnType() { + assertThat(MorphiumTransactionalInterceptor.isAsyncReturnType(String.class)).isFalse(); + } + + // Mutiny is not a dependency of this module (see isAsyncReturnType's javadoc), so + // io.smallrye.mutiny.Uni/Multi cannot be referenced directly, and the detection under test + // is a plain string comparison against those two fully-qualified names. Rather than + // declaring stub classes named io.smallrye.mutiny.Uni/Multi in this test tree -- which + // would occupy a foreign package namespace and collide with the real Mutiny classes the + // moment smallrye-mutiny ever becomes an actual (test or compile) dependency of this + // module -- these two classes are defined on the fly with ByteBuddy (already on the test + // classpath transitively via mockito-core / morphium-core) under exactly the FQNs + // isAsyncReturnType() checks for. This exercises the real name comparison without ever + // creating a source file in a package this module does not own. + private static Class defineClassNamed(String fullyQualifiedName) { + return new ByteBuddy() + .subclass(Object.class) + .name(fullyQualifiedName) + .make() + .load(MorphiumTransactionalInterceptorRetryTest.class.getClassLoader(), + ClassLoadingStrategy.Default.INJECTION) + .getLoaded(); + } + + @Test + @DisplayName("Mutiny's io.smallrye.mutiny.Uni is detected as an async return type (by class name -- " + + "Mutiny is not a compile-time dependency of this module)") + void mutinyUni_isAsyncReturnType() { + Class uniStandIn = defineClassNamed("io.smallrye.mutiny.Uni"); + assertThat(uniStandIn.getName()).isEqualTo("io.smallrye.mutiny.Uni"); + assertThat(MorphiumTransactionalInterceptor.isAsyncReturnType(uniStandIn)).isTrue(); + } + + @Test + @DisplayName("Mutiny's io.smallrye.mutiny.Multi is detected as an async return type (by class name, " + + "same as Uni -- Mutiny is not a compile-time dependency of this module)") + void mutinyMulti_isAsyncReturnType() { + Class multiStandIn = defineClassNamed("io.smallrye.mutiny.Multi"); + assertThat(multiStandIn.getName()).isEqualTo("io.smallrye.mutiny.Multi"); + assertThat(MorphiumTransactionalInterceptor.isAsyncReturnType(multiStandIn)).isTrue(); + } + + // ------------------------------------------------------------------------- + // isNoServerTransaction -- should-fix #8: covers known MongoDB error message + // phrasings for "no server-side transaction to commit/abort", not just one exact string + // ------------------------------------------------------------------------- + + @Test + @DisplayName("\"Cannot start a transaction\" (the original exact match) is still detected") + void originalExactPhrase_isDetected() { + MorphiumDriverException e = new MorphiumDriverException("Cannot start a transaction on a session already started a transaction"); + assertThat(MorphiumTransactionalInterceptor.isNoServerTransaction(e)).isTrue(); + } + + @Test + @DisplayName("differently-cased phrasing is still detected (case-insensitive)") + void differentCasing_isDetected() { + MorphiumDriverException e = new MorphiumDriverException("cannot start a transaction: some detail"); + assertThat(MorphiumTransactionalInterceptor.isNoServerTransaction(e)).isTrue(); + } + + @Test + @DisplayName("\"No such transaction\" phrasing is detected") + void noSuchTransactionPhrasing_isDetected() { + MorphiumDriverException e = new MorphiumDriverException("No such transaction exists for this session"); + assertThat(MorphiumTransactionalInterceptor.isNoServerTransaction(e)).isTrue(); + } + + @Test + @DisplayName("an unrelated MongoDB error message is NOT detected") + void unrelatedError_isNotDetected() { + MorphiumDriverException e = new MorphiumDriverException("E11000 duplicate key error collection"); + assertThat(MorphiumTransactionalInterceptor.isNoServerTransaction(e)).isFalse(); + } + + @Test + @DisplayName("null message is NOT detected (no NPE)") + void nullMessage_isNotDetected() { + MorphiumDriverException e = new MorphiumDriverException((String) null); + assertThat(MorphiumTransactionalInterceptor.isNoServerTransaction(e)).isFalse(); + } +} diff --git a/quarkus-morphium/testing/pom.xml b/quarkus-morphium/testing/pom.xml new file mode 100644 index 000000000..7d37e1ada --- /dev/null +++ b/quarkus-morphium/testing/pom.xml @@ -0,0 +1,46 @@ + + + 4.0.0 + + + de.caluga + quarkus-morphium-parent + 6.3.2-SNAPSHOT + + + quarkus-morphium-testing + Quarkus Morphium Extension – Testing + + Test utilities for applications using the quarkus-morphium extension. + Provides InMemMorphiumTestProfile to run Quarkus tests against the + Morphium in-memory driver without starting a MongoDB container. + + + + + ${project.groupId} + quarkus-morphium + ${project.version} + + + io.quarkus + quarkus-junit + + + + + + + + org.apache.maven.plugins + maven-source-plugin + + + org.apache.maven.plugins + maven-javadoc-plugin + + + + diff --git a/quarkus-morphium/testing/src/main/java/de/caluga/morphium/quarkus/testing/InMemMorphiumTestProfile.java b/quarkus-morphium/testing/src/main/java/de/caluga/morphium/quarkus/testing/InMemMorphiumTestProfile.java new file mode 100644 index 000000000..69fb91fd9 --- /dev/null +++ b/quarkus-morphium/testing/src/main/java/de/caluga/morphium/quarkus/testing/InMemMorphiumTestProfile.java @@ -0,0 +1,56 @@ +/* + * Copyright 2025 The Quarkiverse Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.caluga.morphium.quarkus.testing; + +import io.quarkus.test.junit.QuarkusTestProfile; + +import java.util.Map; + +/** + * Quarkus test profile that configures Morphium to use the in-memory driver. + * + *

    Apply this profile to any {@code @QuarkusTest} class that should run without + * a MongoDB container: + * + *

    {@code
    + * @QuarkusTest
    + * @TestProfile(InMemMorphiumTestProfile.class)
    + * class MyRepositoryTest { ... }
    + * }
    + * + *

    This profile sets the following configuration overrides: + *

      + *
    • {@code quarkus.morphium.driver-name=InMemDriver} – activates the in-process driver
    • + *
    • {@code quarkus.morphium.database=inmem-test} – isolated test database name
    • + *
    • {@code quarkus.morphium.devservices.enabled=false} – prevents a MongoDB + * container from being started alongside the in-memory driver
    • + *
    + * + *

    Tests annotated with this profile can coexist with regular {@code @QuarkusTest} + * classes that rely on Dev Services (a real MongoDB container). Quarkus restarts the + * application context once for each distinct profile encountered in the test suite. + */ +public class InMemMorphiumTestProfile implements QuarkusTestProfile { + + @Override + public Map getConfigOverrides() { + return Map.of( + "quarkus.morphium.driver-name", "InMemDriver", + "quarkus.morphium.database", "inmem-test", + "quarkus.morphium.devservices.enabled", "false" + ); + } +} diff --git a/release.sh b/release.sh index b95bff4ea..2e2743a1a 100755 --- a/release.sh +++ b/release.sh @@ -7,16 +7,24 @@ set -eo pipefail # This script handles the complete release process for the multi-module project: # 1. Validates prerequisites (branch, credentials, GPG, Java) # 2. Runs tests (optional) -# 3. Aligns POM versions if necessary -# 4. Prepares release (creates tag, bumps next SNAPSHOT via maven-release-plugin) -# 5. Builds release artifacts for all modules -# 6. Creates combined bundle (parent + all modules in MODULE_DIRS, see the +# 3. Aligns POM versions if necessary; bumps README version snippets +# 4. Reports on the test-results store (scripts/test_report.py) - never +# blocks the release (badges live on the test-results store branch, kept +# current by scripts/updateReleaseReport.sh, not committed here) +# 5. Prepares release (creates tag, bumps next SNAPSHOT via maven-release-plugin) +# 6. Builds release artifacts for all modules +# 7. Creates combined bundle (parent + all modules in MODULE_DIRS, see the # "Module registry" section below) -# 7. Signs & generates checksums for all artifacts -# 8. Uploads bundle to Sonatype Central Portal -# 9. Merges tag to master and pushes changes -# 10. Deploys documentation to gh-pages (optional) -# 11. Finalizes state and provides summary +# 8. Signs & generates checksums for all artifacts +# 9. Uploads bundle to Sonatype Central Portal +# 10. Merges tag to master and pushes changes; attaches the test report to the +# GitHub release (best-effort, needs authenticated gh) +# 11. Deploys documentation to gh-pages (optional) +# 12. Finalizes state and provides summary +# +# Note: in-code step comments keep their original numbers (Step 1..11) with +# 4b/9b suffixes for these two additions, to keep this diff minimal — the +# list above is the narrative order, not a literal grep target. # # Note: Can skip to Step 8 (Upload) using --skip-to-upload if previous run failed, this can happen when # the Sonatype credentials are not correct @@ -62,6 +70,11 @@ SKIP_TO_UPLOAD=false ROLLBACK=false RESET=false +# Working dir for release-scoped scratch files (e.g. the test-results +# report's markdown, read back later by the GitHub-release step). Created +# unconditionally below and removed by the cleanup() trap on any exit path. +RELEASE_TMP="" + # Parse command line arguments while [[ $# -gt 0 ]]; do case $1 in @@ -106,7 +119,13 @@ while [[ $# -gt 0 ]]; do shift ;; --help) - sed -n '4,44p' "$0" | sed 's/^# //' | sed 's/^#//' + # Print from line 4 through the header comment block's closing banner: + # the block runs as contiguous "#"-prefixed lines, terminated by the + # first truly blank line in the file (the one separating the header + # from "# Colors for output" below) - so this self-adjusts as the + # header comment grows/shrinks instead of rotting like a hardcoded + # end-line number (was '4,44p', silently undercounting after edits). + sed -n '4,/^$/p' "$0" | sed 's/^# //' | sed 's/^#//' exit 0 ;; *) @@ -136,12 +155,26 @@ done # (e.g. quarkus-morphium/integration-tests) should still list modules # explicitly here rather than glob-discovering directories, so such submodules # are simply never added to the arrays. -MODULE_DIRS=(morphium-core poppydb morphium-jakarta-data) -MODULE_ARTIFACT_IDS=(morphium poppydb morphium-jakarta-data) -MODULE_EXTRA_CLASSIFIERS=("" "cli" "") +MODULE_DIRS=(morphium-core poppydb morphium-jakarta-data quarkus-morphium/runtime quarkus-morphium/deployment quarkus-morphium/testing spring-boot-morphium/morphium-spring-boot-autoconfigure spring-boot-morphium/morphium-spring-boot-starter spring-boot-morphium/morphium-spring-boot-test) +MODULE_ARTIFACT_IDS=(morphium poppydb morphium-jakarta-data quarkus-morphium quarkus-morphium-deployment quarkus-morphium-testing morphium-spring-boot-autoconfigure morphium-spring-boot-starter morphium-spring-boot-test) +MODULE_EXTRA_CLASSIFIERS=("" "cli" "" "" "" "" "" "" "") # All module pom.xml paths plus the root pom.xml, for git add/commit calls. -ALL_POM_FILES=(pom.xml) +# Note: MODULE_DIRS only lists directories that hold a *published* artifact +# (see the registry comment above), so it does not cover every pom.xml that +# actually lives in the Maven reactor. Two kinds of reactor poms need to be +# added explicitly here even though they are not in MODULE_DIRS: +# - intermediate parent poms for a multi-submodule extension (packaging=pom, +# handled as its own special case like morphium-parent/quarkus-morphium-parent +# above, never through add_module_to_bundle()) +# - test-only submodules that are built and versioned by the reactor but +# deliberately excluded from the release bundle (e.g. +# quarkus-morphium/integration-tests) +# `mvn versions:set` bumps every pom.xml in the reactor regardless of whether +# it is listed here, so any pom missing from this array would silently be +# version-bumped by Maven but NOT staged by the `git add "${ALL_POM_FILES[@]}"` +# calls below — leaving it out of sync with the commit. +ALL_POM_FILES=(pom.xml quarkus-morphium/pom.xml quarkus-morphium/integration-tests/pom.xml spring-boot-morphium/pom.xml) for _module_dir in "${MODULE_DIRS[@]}"; do ALL_POM_FILES+=("${_module_dir}/pom.xml") done @@ -228,6 +261,45 @@ checksum_file() { fi } +# Bump the version in both READMEs from to - but ONLY in the +# machine-readable spots: X.Y.Z dependency snippets, +# poppydb-X.Y.Z-cli.jar mentions, and de.caluga:poppydb:X.Y.Z coordinates. +# Deliberately NOT a blanket old->new replace: prose like the +# "Patch releases 6.2.1 - 6.2.10" summary describes CONTENT and must only ever +# be extended by a human who also updates the text. The README title has been +# versionless since 2026-08-06 (the Maven Central badge shows the current +# release), so titles never need bumping. No-op for files where the old +# version does not appear (e.g. already bumped by hand). BSD/macOS-sed +# compatible (-i.relbak + rm, matching this script's bash-3.2 portability bar). +bump_readme_versions() { + local old_version="$1" + local new_version="$2" + local old_esc="${old_version//./\\.}" + local file bumped="" + + for file in README.md README.de.md; do + [ -f "$file" ] || continue + if grep -qE "${old_esc}|poppydb-${old_esc}-cli\.jar|de\.caluga:poppydb:${old_esc}|de/caluga/poppydb/${old_esc}/" "$file"; then + sed -i.relbak -E \ + -e "s|${old_esc}|${new_version}|g" \ + -e "s|poppydb-${old_esc}-cli\.jar|poppydb-${new_version}-cli.jar|g" \ + -e "s|de\.caluga:poppydb:${old_esc}|de.caluga:poppydb:${new_version}|g" \ + -e "s|de/caluga/poppydb/${old_esc}/|de/caluga/poppydb/${new_version}/|g" \ + "$file" + rm -f "${file}.relbak" + bumped="${bumped:+$bumped }$file" + fi + done + + if [ -n "$bumped" ]; then + git add $bumped + git commit -m "Update README version snippets to ${new_version} for release" -q + log_success "README version snippets bumped to ${new_version} (${bumped})" + else + log_info "README version snippets already current - nothing to bump" + fi +} + # Copy, sign and checksum one module's artifacts into the bundle staging area. # Usage: add_module_to_bundle [allow_snapshot_fallback] # @@ -336,11 +408,88 @@ upload_bundle() { fi } +# Report on the decoupled test-results store (scripts/test_report.py, see +# .superpowers/sdd/2026-08-13-test-results-store/) for HEAD: aggregates +# whatever scope=complete records cover HEAD (or an allowlisted-diff ancestor +# of it) per required phase (inmem/mongodb_rs/poppydb_rs/mongodb_single/ +# poppydb_single). This is a REPORT, not a gate - "Transparenz statt +# Türsteher": it never aborts the release. Exit codes from test_report.py: +# 0 = all required phases complete and green, 1 = gaps or broken tests (the +# release notes will carry the honest table, including the gaps), 3 = infra +# error (store unreachable) - in that case there is nothing to report. The +# markdown already carries the marker-wrapped section (test_report.py); it is +# written to $RELEASE_TMP/test-report.md for publish_github_release_notes() +# below. Badges are no longer produced/committed here - they live on the +# test-results store branch and are kept current by +# scripts/updateReleaseReport.sh, called after every runtests.sh publish. +run_test_results_report() { + log_step "Checking test-results store for HEAD" + + local report_file="$RELEASE_TMP/test-report.md" + local report_status=0 + python3 scripts/test_report.py \ + --target-commit "$(git rev-parse HEAD)" \ + --markdown-out "$report_file" || report_status=$? + + if [ "$report_status" -eq 3 ]; then + log_warn "Test-results store unreachable - skipping test report" + return 0 + elif [ "$report_status" -ne 0 ]; then + log_warn "Test matrix incomplete or broken - release continues, the release notes will say so" + else + log_success "Test matrix complete and green" + fi +} + +# Attach the test-results report to the GitHub release for $tag: create the +# release if it doesn't exist yet, otherwise append the report to whatever +# notes are already there (release:perform / prior manual edits). Entirely +# best-effort - a missing/unauthenticated gh CLI, or gh itself failing, is +# logged as a warning and must never fail the release at this point (upload + +# git merge to master already happened). +publish_github_release_notes() { + if ! command -v gh &>/dev/null; then + log_warn "gh CLI not found - skipping GitHub release notes" + return 0 + fi + if ! gh auth status &>/dev/null; then + log_warn "gh CLI not authenticated - skipping GitHub release notes" + return 0 + fi + + local report_file="$RELEASE_TMP/test-report.md" + if [ ! -f "$report_file" ]; then + log_warn "No test-results report available - skipping GitHub release notes" + return 0 + fi + + if gh release view "$tag" >/dev/null 2>&1; then + local body + if ! body=$(gh release view "$tag" --json body -q .body); then + log_warn "Failed to read existing GitHub release body for $tag - skipping GitHub release notes" + return 0 + fi + if ! printf '%s\n\n%s\n' "$body" "$(cat "$report_file")" | gh release edit "$tag" --notes-file -; then + log_warn "Failed to update GitHub release notes for $tag" + return 0 + fi + else + if ! gh release create "$tag" --title "Morphium $tag" --notes-file "$report_file"; then + log_warn "Failed to create GitHub release $tag" + return 0 + fi + fi + log_success "Test report attached to GitHub release $tag" +} + cleanup() { local exit_code=$? if [ -n "$BUNDLE_DIR" ] && [ -d "$BUNDLE_DIR" ]; then rm -rf "$BUNDLE_DIR" fi + if [ -n "$RELEASE_TMP" ] && [ -d "$RELEASE_TMP" ]; then + rm -rf "$RELEASE_TMP" + fi # Always return to the original branch on exit if [ -n "$ORIGINAL_BRANCH" ]; then current=$(git symbolic-ref --short HEAD 2>/dev/null || echo "detached") @@ -362,6 +511,10 @@ trap cleanup EXIT # Record starting branch early so cleanup trap can return here on any error ORIGINAL_BRANCH=$(git symbolic-ref --short HEAD 2>/dev/null || echo "") +# Scratch dir for this run (test-results report markdown, read back by the +# GitHub-release step); cleaned up by the cleanup() trap above. +RELEASE_TMP=$(mktemp -d) + # ----------------------------------------------------------------------------- # Rollback handler # ----------------------------------------------------------------------------- @@ -739,6 +892,16 @@ else log_success "POM version: $current_version" fi +# Keep the README dependency snippets in sync with the release - they used to +# rot silently (the README still said 6.2.4 while v6.2.10 was long out). Must +# happen before release:prepare, which requires a clean working tree; the +# helper commits on its own when it changed anything. +if [ "$DRY_RUN" = true ]; then + log_info "Not bumping README versions because of DRY_RUN" +else + bump_readme_versions "$last_version" "$release_version" +fi + # Verify multi-module structure for module_dir in "${MODULE_DIRS[@]}"; do if [ ! -f "$module_dir/pom.xml" ]; then @@ -750,7 +913,7 @@ module_list="" for module_dir in "${MODULE_DIRS[@]}"; do module_list="${module_list:+$module_list, }$module_dir" done -log_success "Multi-module structure: morphium-parent, ${module_list}" +log_success "Multi-module structure: morphium-parent, quarkus-morphium-parent, morphium-spring-boot-parent, ${module_list}" fi # ----------------------------------------------------------------------------- @@ -802,6 +965,20 @@ if [ "$DRY_RUN" = true ]; then sign_file "${parent_repo}/morphium-parent-${version}.pom" checksum_file "${parent_repo}/morphium-parent-${version}.pom" + log_info "Adding quarkus-morphium-parent..." + quarkus_parent_repo="${BUNDLE_DIR}/de/caluga/quarkus-morphium-parent/${version}" + mkdir -p "$quarkus_parent_repo" + cp quarkus-morphium/pom.xml "${quarkus_parent_repo}/quarkus-morphium-parent-${version}.pom" + sign_file "${quarkus_parent_repo}/quarkus-morphium-parent-${version}.pom" + checksum_file "${quarkus_parent_repo}/quarkus-morphium-parent-${version}.pom" + + log_info "Adding morphium-spring-boot-parent..." + spring_parent_repo="${BUNDLE_DIR}/de/caluga/morphium-spring-boot-parent/${version}" + mkdir -p "$spring_parent_repo" + cp spring-boot-morphium/pom.xml "${spring_parent_repo}/morphium-spring-boot-parent-${version}.pom" + sign_file "${spring_parent_repo}/morphium-spring-boot-parent-${version}.pom" + checksum_file "${spring_parent_repo}/morphium-spring-boot-parent-${version}.pom" + for i in "${!MODULE_DIRS[@]}"; do add_module_to_bundle \ "${MODULE_DIRS[$i]}" \ @@ -818,7 +995,7 @@ if [ "$DRY_RUN" = true ]; then log_step "Dry run complete" echo "" echo "Would release version: $release_version" - echo " Modules: morphium-parent, ${MODULE_ARTIFACT_IDS[*]}" + echo " Modules: morphium-parent, quarkus-morphium-parent, morphium-spring-boot-parent, ${MODULE_ARTIFACT_IDS[*]}" echo " From branch: $branch" echo "" echo "Bundle contents:" @@ -841,7 +1018,7 @@ if [ "$SKIP_TO_UPLOAD" != true ]; then echo " Last release: $last_tag" echo " Release version: $release_version (--${BUMP_TYPE})" echo " Next development: $next_snapshot" - echo " Modules: morphium-parent, ${MODULE_ARTIFACT_IDS[*]}" + echo " Modules: morphium-parent, quarkus-morphium-parent, morphium-spring-boot-parent, ${MODULE_ARTIFACT_IDS[*]}" echo " Branch: $branch" echo " Auto-publish: $AUTO_PUBLISH" echo "" @@ -852,6 +1029,19 @@ if [ "$SKIP_TO_UPLOAD" != true ]; then fi fi +# ----------------------------------------------------------------------------- +# Step 4b: Test-results report +# ----------------------------------------------------------------------------- +# Reports on the decoupled test-results store instead of the old opt-in +# `--run-tests` (mvn clean test locally, never covering the real +# inmem/mongodb_rs/poppydb_rs/mongodb_single/poppydb_single matrix). Runs +# unconditionally in the default path now; it never blocks the release - +# gaps and broken phases just get reported honestly in the release notes. + +if [ "$SKIP_TO_UPLOAD" != true ]; then + run_test_results_report +fi + # ----------------------------------------------------------------------------- # Step 5: Maven release:prepare (tag + version bump) # ----------------------------------------------------------------------------- @@ -942,6 +1132,27 @@ if [ "$SKIP_TO_UPLOAD" != true ]; then sign_file "${parent_repo}/morphium-parent-${version}.pom" checksum_file "${parent_repo}/morphium-parent-${version}.pom" + # --- quarkus-morphium-parent (POM-only, same special case as + # morphium-parent above: add_module_to_bundle() always expects + # jar+sources+javadoc, which does not apply to a packaging=pom module) --- + log_info "Adding quarkus-morphium-parent..." + quarkus_parent_repo="${BUNDLE_DIR}/de/caluga/quarkus-morphium-parent/${version}" + mkdir -p "$quarkus_parent_repo" + + cp quarkus-morphium/pom.xml "${quarkus_parent_repo}/quarkus-morphium-parent-${version}.pom" + sign_file "${quarkus_parent_repo}/quarkus-morphium-parent-${version}.pom" + checksum_file "${quarkus_parent_repo}/quarkus-morphium-parent-${version}.pom" + + # --- morphium-spring-boot-parent (POM-only, same special case as + # morphium-parent/quarkus-morphium-parent above) --- + log_info "Adding morphium-spring-boot-parent..." + spring_parent_repo="${BUNDLE_DIR}/de/caluga/morphium-spring-boot-parent/${version}" + mkdir -p "$spring_parent_repo" + + cp spring-boot-morphium/pom.xml "${spring_parent_repo}/morphium-spring-boot-parent-${version}.pom" + sign_file "${spring_parent_repo}/morphium-spring-boot-parent-${version}.pom" + checksum_file "${spring_parent_repo}/morphium-spring-boot-parent-${version}.pom" + # --- one block per registered module (see MODULE_DIRS/MODULE_ARTIFACT_IDS # /MODULE_EXTRA_CLASSIFIERS above); analogous to the former morphium/poppydb # copy-paste blocks, now driven by add_module_to_bundle() so a future module @@ -974,7 +1185,7 @@ if [ "$SKIP_TO_UPLOAD" != true ]; then (cd "$BUNDLE_DIR" && zip -q -r "$(pwd)/../bundle-${version}.jar" de/) log_success "Combined bundle: $bundle_file ($(du -h "$bundle_file" | cut -f1))" - log_info " Contents: morphium-parent (pom), ${MODULE_ARTIFACT_IDS[*]} (jar+sources+javadoc, plus extra classifiers where applicable)" + log_info " Contents: morphium-parent (pom), quarkus-morphium-parent (pom), morphium-spring-boot-parent (pom), ${MODULE_ARTIFACT_IDS[*]} (jar+sources+javadoc, plus extra classifiers where applicable)" fi # ----------------------------------------------------------------------------- @@ -1050,6 +1261,12 @@ for _module_dir in "${MODULE_DIRS[@]}"; do rm -f "${_module_dir}/pom.xml.releaseBackup" 2>/dev/null || true done +# ----------------------------------------------------------------------------- +# Step 9b: Publish test report to the GitHub release +# ----------------------------------------------------------------------------- + +publish_github_release_notes + # ----------------------------------------------------------------------------- # Step 10: Deploy documentation (optional) # ----------------------------------------------------------------------------- diff --git a/runtests.sh b/runtests.sh index a1893afee..c71c6a74a 100755 --- a/runtests.sh +++ b/runtests.sh @@ -156,6 +156,104 @@ function quitting() { fi } +# Publish this run's results to the decoupled test-results store (opt-in via --publish-results). +# Called from both the parallel and sequential end-of-run paths. Never fails the test run: +# every error path below is swallowed (echo + return 0), on purpose. +function publish_test_results() { + # phase identity: explicit --phase override wins, else derive from driver/backend + local phase="$PHASE_OVERRIDE" + if [ -z "$phase" ]; then + case "$driver" in + inmem) + phase="inmem" + ;; + *) + if [ "$startPoppydbLocal" -eq 1 ]; then + if [ "$poppydbSingleNode" -eq 1 ]; then + phase="poppydb_single" + else + phase="poppydb_rs" + fi + else + # multi-host or replicaSet= URI means replica set + local effective_uri="${uri:-$MONGODB_URI}" + case "$effective_uri" in + *replicaSet=* | *,*) + phase="mongodb_rs" + ;; + *) + phase="mongodb_single" + ;; + esac + fi + ;; + esac + fi + + local publish_args=() + [ -n "$SCOPE_TAGS" ] && publish_args+=(--tags "$SCOPE_TAGS") + [ -n "$SCOPE_PATTERN" ] && publish_args+=(--test-pattern "$SCOPE_PATTERN") + + # Resolve commit/branch. In a phase-orchestrator run this script itself lives + # in a symlink farm workdir (mirrors the repo but has no .git), so a plain + # "git rev-parse HEAD" here comes back empty - not a git error, since bash 3.2's + # `git` still finds *some* enclosing .git via cwd in the general case, but the + # workdir has none at all. Fall back to resolving the runtests.sh symlink's + # real path (python3 is already a hard dependency of this function, so no + # readlink -f needed) and asking the checkout it actually points into. + local commit branch script_repo + commit=$(git rev-parse HEAD 2>/dev/null || true) + if [ -z "$commit" ]; then + script_repo=$(python3 -c "import os,sys; print(os.path.dirname(os.path.realpath(sys.argv[1])))" "$0") + commit=$(git -C "$script_repo" rev-parse HEAD 2>/dev/null || true) + branch=$(git -C "$script_repo" rev-parse --abbrev-ref HEAD 2>/dev/null || true) + else + branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true) + fi + if [ -z "$commit" ]; then + echo -e "${YL}Info:${CL} could not resolve a git commit (not a git checkout, even via symlink target) - skipping results publish" + return 0 + fi + [ -z "$branch" ] && branch="unknown" + + local record_json + record_json=$(python3 "$(dirname "$0")/scripts/test_results_record.py" \ + --logdir "$LOGDIR" --phase "$phase" \ + --runner "${RUNNER_LABEL:-$(hostname -s)}" \ + --commit "$commit" \ + --branch "$branch" \ + --duration-s "$(($(date +%s) - TESTS_STARTED_AT))" \ + "${publish_args[@]}") + local record_rc=$? + + if [ "$record_rc" -eq 2 ]; then + echo -e "${YL}Info:${CL} no parsable test logs in $LOGDIR - skipping results publish" + return 0 + elif [ "$record_rc" -ne 0 ]; then + echo -e "${RD}publishing test results failed (tests unaffected)${CL} - could not build record (exit $record_rc)" + return 0 + fi + + local publisher_args=() + if [ "${MORPHIUM_PUBLISH_DRYRUN:-0}" = "1" ]; then + publisher_args+=(--dry-run) + fi + + local publish_rc=0 + echo "$record_json" | "$(dirname "$0")/scripts/publishTestResults.sh" "${publisher_args[@]}" \ + || { echo -e "${RD}publishing test results failed (tests unaffected)${CL}"; publish_rc=1; } + + # Living report: after a real (non-dry-run) successful publish, best-effort + # refresh the latest release's notes section + badges from the store. + # updateReleaseReport.sh has its own guards (missing/unauthenticated gh, + # no tag, store unreachable, ...) - "|| true" here just protects against + # it failing outright, it should never affect the test run's exit status. + if [ "$publish_rc" -eq 0 ] && [ "${MORPHIUM_PUBLISH_DRYRUN:-0}" != "1" ]; then + "$(dirname "$0")/scripts/updateReleaseReport.sh" || true + fi + return 0 +} + source "$(dirname "$0")/scripts/stats.sh" # Aggregate per-slot logs into the shared "" directory so stats work after interruptions. @@ -250,6 +348,9 @@ poppydbMaxConnections="" poppydbSocketTimeout="" testname="" # Stores the class pattern from --test methodname="." # Stores the method pattern from --test (defaults to all methods) +PUBLISH_RESULTS=0 +RUNNER_LABEL="" +PHASE_OVERRIDE="" # Save original arguments for stats processing original_args=("$@") @@ -292,6 +393,10 @@ while [ "q$1" != "q" ]; do echo -e "${BL}--taillog$CL - tails a specific log" echo -e "${BL}--showfailed$CL - let's you choose a log from failed test classes to view" echo -e "${BL}--stats$CL - show test statistics and failed tests (replaces getStats.sh)" + echo -e "${BL}--publish-results$CL - publish this run's results to the test-results store" + echo -e "${BL}--runner-label$CL ${GN}NAME$CL - label identifying this runner in the published record (default: hostname)" + echo -e "${BL}--phase$CL ${GN}NAME$CL - override the auto-detected phase name in the published record" + echo -e " ${YL}NOTE:${CL} set ${GN}MORPHIUM_PUBLISH_DRYRUN=1$CL to publish in --dry-run mode" echo -e "if neither ${BL}--restart${CL} nor ${BL}--skip${CL} are set, you will be asked what to do" echo echo -e "${YL}Tag Examples:${CL}" @@ -501,6 +606,17 @@ while [ "q$1" != "q" ]; do fi skip=1 # Implies skipping confirmation if --test is used shift + elif [ "q$1" == "q--publish-results" ]; then + PUBLISH_RESULTS=1 + shift + elif [ "q$1" == "q--runner-label" ]; then + shift + RUNNER_LABEL=$1 + shift + elif [ "q$1" == "q--phase" ]; then + shift + PHASE_OVERRIDE=$1 + shift else echo "Unknown option $1" exit 1 @@ -514,6 +630,15 @@ if [ -z "$parallel" ]; then parallel=1 fi +# Capture the run's scope (tags/pattern) for the published test-results record, regardless +# of the order --tags/--test/--rerunfailed were given on the command line. +SCOPE_TAGS="$includeTags" +if [ "$rerunfailed" -eq 1 ]; then + SCOPE_PATTERN="rerunfailed" +else + SCOPE_PATTERN="$test_pattern" +fi + # Set default driver to inmem if none specified and no external mode # Conflict detection @@ -640,6 +765,18 @@ if [[ ! "$includeTags" == *"manual"* ]]; then fi fi +# 'benchmark' tagged tests (timing-sensitive perf benchmarks, e.g. PerformanceBenchmarkTest) are +# not part of the regular suite - same reasoning/mechanism as 'manual' above: the pom default +# excludes them, but a self-built -Dtest.excludeTags overrides that default, so 'benchmark' has +# to be re-added here too. Only an explicit --tags benchmark (plus --exclude-tags '') runs them. +if [[ ! "$includeTags" == *"benchmark"* ]]; then + if [ -z "$excludeTags" ]; then + excludeTags="benchmark" + elif [[ ! "$excludeTags" == *"benchmark"* ]]; then + excludeTags="$excludeTags,benchmark" + fi +fi + # Handle --rerunfailed option early to bypass interactive prompts if [ "$rerunfailed" -eq 1 ]; then echo -e "${MG}Rerunning${CL} ${CN}failed tests...${CL}" @@ -1031,6 +1168,7 @@ fi TEST_MVN_PROPS="$MVN_PROPS -Dmaven.compiler.skip=true" tst=0 +TESTS_STARTED_AT=$(date +%s) # Wall-clock start of this run, used for the published duration-s echo -e "${GN}Starting tests..${CL}" >"$TEST_TMP_DIR/failed.txt" # running getfailedTests in background { @@ -1601,13 +1739,53 @@ function run_parallel_tests() { # Cleanup temporary files rm -f "$TEST_TMP_DIR"/test_chunk_*.txt + + # Stop the background stats loop. It polls "while [ -e $runLock ]", so the lock has to + # go or it runs forever - which used to be exactly what happened here, because only the + # sequential branch below ever removed it. Two symptoms came out of that: a leftover + # bash process showing the SAME command line as this script (a "{ ... } &" subshell is + # a fork, so ps cannot tell them apart) that looked like a still-running test run, and + # a caller piping our output (./runtests.sh | tail) hanging forever, because the + # surviving subshell inherited - and never closed - our stdout. Kill it explicitly too + # rather than waiting out a full refresh interval for it to notice the missing lock. + rm -f $runLock + if [ -e $failPid ]; then + # wait after kill, otherwise bash prints the whole job body as a "Terminated" notice + { + kill $(<$failPid) + wait $(<$failPid) + } >/dev/null 2>&1 + fi + rm -f $failPid >/dev/null 2>&1 + + # Persist the failed-test list like the sequential branch does, so --rerunfailed and a + # post-mortem have the same input regardless of which branch produced the run. + if [ $total_failed -gt 0 ]; then + get_test_stats >"$TEST_TMP_DIR/failed.txt" 2>/dev/null + cp "$TEST_TMP_DIR/failed.txt" "$LOGDIR/failed.txt" 2>/dev/null + echo -e "${YL}List of failed tests in $LOGDIR/failed.txt${CL}" + fi + echo -e "${GN}Parallel execution completed${CL}" + + # Report failure to the caller. The sequential branch exits 1 on failures; this one + # always exited 0, so a red parallel run passed as green in any CI or scripted use. + [ $total_failed -eq 0 ] } ################################################################################################################## #######MAIN LOOP if [ $parallel -gt 1 ]; then run_parallel_tests + parallelResult=$? + if [ "$PUBLISH_RESULTS" = "1" ]; then + publish_test_results + fi + # quitting() does the shared teardown (test databases, PoppyDB, temp dir) that the + # sequential branch reaches through its own exit paths - without it a parallel run + # leaves its /tmp/morphium-runtests-$PID directory behind on every invocation. + quitting + exit $parallelResult else # Original sequential logic for t in $(<$classList); do @@ -1855,11 +2033,17 @@ else if [ -z "$unsuc" ] || [ "$unsuc" -eq 0 ]; then echo -e "${GN}no errors recorded$CL" rm -f "$TEST_TMP_DIR/failed.txt" + if [ "$PUBLISH_RESULTS" = "1" ]; then + publish_test_results + fi quitting else # Copy failed.txt to $LOGDIR/ so it persists after cleanup cp "$TEST_TMP_DIR/failed.txt" "$LOGDIR/failed.txt" 2>/dev/null echo -e "${RD}There were errors$CL: fails $fail + errors $err = $unsuc - List of failed tests in $LOGDIR/failed.txt" + if [ "$PUBLISH_RESULTS" = "1" ]; then + publish_test_results + fi quitting exit 1 fi diff --git a/scripts/publishTestResults.sh b/scripts/publishTestResults.sh new file mode 100755 index 000000000..55de19c21 --- /dev/null +++ b/scripts/publishTestResults.sh @@ -0,0 +1,96 @@ +#!/bin/bash +# Publish a test-results record (JSON on stdin) to the append-only orphan +# branch `test-results`. Unique filenames make conflicts impossible; concurrent +# pushers only ever need a fetch+retry. bash 3.2 compatible. +set -eo pipefail + +# Resolve the real repo root from this script's own location rather than the +# caller's CWD. Callers in CI phase workdirs (/tmp/morphium-phase-workdir-*) +# invoke this script through a symlink sitting in a non-git symlink farm - +# `dirname "$0"` alone stays inside that farm, so it has to be dereferenced +# (python3 os.path.realpath; bash 3.2 has no readlink -f) back to where the +# script file actually lives, i.e. inside the real checkout. +REPO_DIR=$(python3 -c 'import os,sys; print(os.path.dirname(os.path.dirname(os.path.realpath(sys.argv[1]))))' "$0") +git -C "$REPO_DIR" rev-parse --git-dir >/dev/null 2>&1 || { echo "error: cannot resolve real repo root from $0 (resolved: $REPO_DIR)" >&2; exit 1; } + +REMOTE=origin +DRY_RUN=0 +BRANCH=test-results +while [ $# -ne 0 ]; do + case "$1" in + --dry-run) DRY_RUN=1; shift ;; + --remote) REMOTE="$2"; shift 2 ;; + *) echo "unknown option: $1" >&2; exit 1 ;; + esac +done + +RECORD=$(cat) +# filename fields straight from the record so file and content cannot diverge +FILE=$(printf '%s' "$RECORD" | python3 -c ' +import json, re, sys +try: + r = json.load(sys.stdin) + ts = r["timestamp"].replace(":", "-") + commit8 = r["commit"][:8] + runner = re.sub(r"[^A-Za-z0-9_-]", "", r["runner"].split(".")[0]) or "unknown" + scope = "full" if r["scope"]["complete"] else "partial" + phases = "-".join(sorted(r["phases"])) + for field in (ts, commit8, phases): + if not re.fullmatch(r"[A-Za-z0-9._-]+", field): + raise ValueError("unsafe field content: %r" % field) + print("%s_%s_%s_%s-%s.json" % (ts, commit8, runner, scope, phases)) +except Exception as e: + print("error: invalid record: %s" % e, file=sys.stderr) + sys.exit(1) +') || { echo "error: refusing to publish invalid record" >&2; exit 1; } +case "$FILE" in *[!A-Za-z0-9._-]*|"") echo "error: unsafe filename: $FILE" >&2; exit 1 ;; esac + +WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/morphium-testresults.XXXXXX") +trap 'rm -rf "$WORKDIR"' EXIT +REMOTE_URL=$(git -C "$REPO_DIR" remote get-url "$REMOTE") + +if git ls-remote --exit-code --heads "$REMOTE_URL" "$BRANCH" >/dev/null 2>&1; then + git clone -q --depth 1 --branch "$BRANCH" "$REMOTE_URL" "$WORKDIR/store" +else + git init -q "$WORKDIR/store" + (cd "$WORKDIR/store" \ + && git checkout -q --orphan "$BRANCH" \ + && git remote add "$REMOTE" "$REMOTE_URL" \ + && printf '%s\n' "# Morphium test results" "" \ + "Append-only store of test-run records. One JSON file per run, written by" \ + "scripts/publishTestResults.sh (see docs in the main branches). Do not edit." \ + > README.md \ + && git add README.md \ + && git -c user.name="${MORPHIUM_RESULTS_GIT_NAME:-morphium-test-results}" \ + -c user.email="${MORPHIUM_RESULTS_GIT_EMAIL:-test-results@morphium.invalid}" \ + commit -q -m "chore: bootstrap test-results store") +fi + +cd "$WORKDIR/store" +printf '%s\n' "$RECORD" > "$FILE" +git add "$FILE" +# -c user.name/-c user.email give this commit an identity even on a fresh +# machine with no git user.* configured (hit for real on the CI testrunner: +# "fatal: empty ident name" silently dropped a publish) - env vars let a +# runner customize it, the default is a neutral bot identity either way. +git -c user.name="${MORPHIUM_RESULTS_GIT_NAME:-morphium-test-results}" \ + -c user.email="${MORPHIUM_RESULTS_GIT_EMAIL:-test-results@morphium.invalid}" \ + commit -q -m "results: $FILE" + +if [ "$DRY_RUN" -eq 1 ]; then + echo "dry-run: would push $FILE to $REMOTE/$BRANCH" + exit 0 +fi + +n=0 +while ! git push -q "$REMOTE" "HEAD:refs/heads/$BRANCH" 2>/dev/null; do + n=$((n + 1)) + if [ "$n" -gt 5 ]; then + echo "error: push failed after 5 retries" >&2 + exit 1 + fi + # non-fast-forward: someone else pushed; replay our unique file on top + git fetch -q "$REMOTE" "$BRANCH" + git rebase -q "FETCH_HEAD" || { git rebase --abort; exit 1; } +done +echo "published $FILE to $REMOTE/$BRANCH" diff --git a/scripts/startPoppyDB.sh b/scripts/startPoppyDB.sh index a734de71f..624e9d645 100755 --- a/scripts/startPoppyDB.sh +++ b/scripts/startPoppyDB.sh @@ -114,7 +114,9 @@ if [ ! -e $TMPDIR ]; then mkdir $TMPDIR fi if $COMPILE; then - mvn -Dmaven.test.skip=true -Dmaven.javadoc.skip=true package -pl poppydb -am || exit 1 + # -DskipTests (not -Dmaven.test.skip=true): poppydb depends on the morphium + # test-jar, which only gets built when the test classes are compiled + mvn -DskipTests -Dmaven.javadoc.skip=true package -pl poppydb -am || exit 1 # resolve the current project version from the pom - stale jars from older # versions may still be lying around in target/ POMVERSION=$(sed -n 's/.*\(.*\)<\/version>.*/\1/p' pom.xml | head -n 1) @@ -182,19 +184,25 @@ else p=$BASEPORT for n in $(seq $NODES); do if [ $ONLYNODE -eq 0 ] || [ $ONLYNODE -eq $n ]; then + # Skip via else (NOT `continue`): the port increment at the loop bottom must still run, + # otherwise every later node would shift onto the wrong port. Starting anyway would be + # worse still - the new JVM can't bind, but its pid would already have clobbered + # node-$n.pid, and the failure branch below would then delete the pid file of the process + # that IS still running, orphaning it for stop/status. if lsof -Pi :$p -sTCP:LISTEN -t >/dev/null; then echo "Port $p is already in use, skipping node $n" - fi - echo "Starting node $n PoppyDB on port $p, replicaset rstst, prios $prioList, nodes: $nodeList" + else + echo "Starting node $n PoppyDB on port $p, replicaset rstst, prios $prioList, nodes: $nodeList" - java -Xmx8G -jar $TMPDIR/poppydb.jar --no-config -p $p --rs-name tstrs --rs-seed "$nodeList" --rs-priorities "$prioList" $SSL_ARGS >$TMPDIR/poppydb-$n.log 2>&1 & - pid=$! - echo "$pid" >$TMPDIR/node-$n.pid - sleep 1 - if ! kill -0 $pid 2>/dev/null; then - echo "Failed to start node $n PoppyDB, check $TMPDIR/poppydb-$n.log" - cat $TMPDIR/poppydb-$n.log - rm $TMPDIR/node-$n.pid + java -Xmx8G -jar $TMPDIR/poppydb.jar --no-config -p $p --rs-name tstrs --rs-seed "$nodeList" --rs-priorities "$prioList" $SSL_ARGS >$TMPDIR/poppydb-$n.log 2>&1 & + pid=$! + echo "$pid" >$TMPDIR/node-$n.pid + sleep 1 + if ! kill -0 $pid 2>/dev/null; then + echo "Failed to start node $n PoppyDB, check $TMPDIR/poppydb-$n.log" + cat $TMPDIR/poppydb-$n.log + rm $TMPDIR/node-$n.pid + fi fi fi let p=p+1 diff --git a/scripts/test_report.py b/scripts/test_report.py new file mode 100644 index 000000000..48801c19c --- /dev/null +++ b/scripts/test_report.py @@ -0,0 +1,251 @@ +#!/usr/bin/env python3 +"""Aggregate test-results records for a target commit into a markdown report. + +Rules (spec 2026-08-13-test-results-store-design.md): +- only scope.complete records count; +- per (phase) the record with the newest timestamp wins among records whose + commit *qualifies* for the target commit; +- commit C qualifies for target R iff C == R, or C is an ancestor of R and + every path in `git diff C..R` matches the allowlist below. + +This tool only *reports*: it aggregates and renders, it never decides whether +a release should proceed. Its exit code is a signal, not a gate - it is the +caller's business whether to treat exit 1 (gaps/broken tests) as fatal, a +warning, or something to ignore entirely. Exit codes: 0 = all REQUIRED_PHASES +covered and broken == 0 everywhere; 1 = gaps or broken tests found; 3 = +infra/fetch failure (store unreachable) - distinct from 1 because it says +nothing about test health. +""" +import argparse +import fnmatch +import json +import os +import subprocess +import sys + +REQUIRED_PHASES = ["inmem", "mongodb_rs", "poppydb_rs", + "mongodb_single", "poppydb_single"] + +# Marks the report section for callers that splice it into a larger document +# (updateReleaseReport.sh replaces everything between these markers in a +# GitHub release body on every re-publish - the "living report"). Keep the +# text stable: it is matched verbatim, not parsed. +MARK_START = "" +MARK_END = "" + +# paths that do not change the released artifact +ALLOW = ["docs/*", "*.md", "branding/*", "mkdocs.yml", "LICENSE", + ".gitignore", "scripts/*", "runtests.sh", "badges/*"] +# allowed too, but the report must say so ("test-only changes since") +ALLOW_ANNOTATE = ["*/src/test/*"] + + +def sh(*cmd): + return subprocess.run(cmd, capture_output=True, text=True) + + +def load_records(): + if sh("git", "fetch", "-q", "origin", "test-results").returncode != 0: + print("error: cannot fetch origin/test-results", file=sys.stderr) + sys.exit(3) + ls = sh("git", "ls-tree", "-r", "--name-only", "FETCH_HEAD") + records = [] + for name in ls.stdout.split(): + if not name.endswith(".json"): + continue + blob = sh("git", "show", "FETCH_HEAD:%s" % name) + try: + records.append(json.loads(blob.stdout)) + except ValueError: + print("warning: skipping unparsable %s" % name, file=sys.stderr) + return records + + +def classify_diff(commit, target): + """'' if identical, 'clean'/'tests' if allowlisted diff, None otherwise.""" + if sh("git", "merge-base", "--is-ancestor", commit, target).returncode != 0: + return None + diff = sh("git", "diff", "--name-only", "%s..%s" % (commit, target)) + if diff.returncode != 0: + return None + files = [f for f in diff.stdout.splitlines() if f.strip()] + if not files: + return "" + verdict = "clean" + for f in files: + if any(fnmatch.fnmatch(f, p) for p in ALLOW): + continue + if any(fnmatch.fnmatch(f, p) for p in ALLOW_ANNOTATE): + verdict = "tests" + continue + return None + return verdict + + +def aggregate(records, target): + chosen = {} # phase -> (record, phase_stats, diff_class) + for rec in records: + if not rec.get("scope", {}).get("complete"): + continue + diff_class = classify_diff(rec["commit"], target) + if diff_class is None: + continue + for phase, stats in rec["phases"].items(): + cur = chosen.get(phase) + if cur is None or rec["timestamp"] > cur[0]["timestamp"]: + chosen[phase] = (rec, stats, diff_class) + return chosen + + +def render_markdown(chosen, target): + lines = ["## Test results", "", + "| Phase | Tests | Passed | Flaky | Broken | Runner | Tested commit | When (UTC) |", + "|---|---|---|---|---|---|---|---|"] + annotate = False + for phase in REQUIRED_PHASES: + if phase not in chosen: + lines.append("| %s | — | — | — | — | *missing* | | |" % phase) + continue + rec, st, diff_class = chosen[phase] + if diff_class == "tests": + annotate = True + lines.append("| %s | %d | %d | %d | %d | %s | %s | %s |" % ( + phase, st["methods"], st["passed"], st.get("flaky", 0), + st["broken"], rec["runner"], rec["commit"][:8], rec["timestamp"])) + # extension-module phases (jakarta-data, quarkus, ...): report-only, never gate-relevant + for phase in sorted(p for p in chosen if p not in REQUIRED_PHASES): + rec, st, diff_class = chosen[phase] + if diff_class == "tests": + annotate = True + lines.append("| %s *(optional)* | %d | %d | %d | %d | %s | %s | %s |" % ( + phase, st["methods"], st["passed"], st.get("flaky", 0), + st["broken"], rec["runner"], rec["commit"][:8], rec["timestamp"])) + cov = None + for phase in REQUIRED_PHASES: + if phase in chosen and chosen[phase][0].get("coverage"): + c = chosen[phase][0] + if cov is None or c["timestamp"] > cov["timestamp"]: + cov = c + if cov: + lines += ["", "**Coverage** (JaCoCo, merged over the full matrix): " + + ", ".join("`%s` %.1f%% line / %.1f%% branch" % + (m, v.get("line", 0), v.get("branch", 0)) + for m, v in sorted(cov["coverage"].items()))] + if annotate: + lines += ["", "_Some results were produced on an earlier commit; only " + "test/doc/tooling files changed since (released artifact identical)._"] + body = "\n".join(lines) + "\n" + return MARK_START + "\n" + body + MARK_END + "\n", cov + + +def write_badges(chosen, cov, badges_dir): + os.makedirs(badges_dir, exist_ok=True) + covered = [p for p in REQUIRED_PHASES if p in chosen] + broken = sum(chosen[p][1]["broken"] for p in covered) + ok = len(covered) == len(REQUIRED_PHASES) and broken == 0 + passed = sum(chosen[p][1]["passed"] for p in covered) + with open(os.path.join(badges_dir, "tests.json"), "w") as fh: + json.dump({"schemaVersion": 1, "label": "tests", + "message": "%d/%d phases, %d passed" % + (len(covered), len(REQUIRED_PHASES), passed), + "color": "brightgreen" if ok else "red"}, fh) + if cov: + lines_pct = [v.get("line", 0) for v in cov["coverage"].values()] + avg = sum(lines_pct) / len(lines_pct) + color = "brightgreen" if avg >= 75 else "yellow" if avg >= 60 else "orange" + with open(os.path.join(badges_dir, "coverage.json"), "w") as fh: + json.dump({"schemaVersion": 1, "label": "coverage", + "message": "%.0f%% line" % avg, "color": color}, fh) + + +def selftest(): + rec = {"schema": 1, "commit": "a" * 40, "branch": "develop", + "timestamp": "2026-08-13T20:00:00Z", "runner": "t", + "scope": {"complete": True, "tags": None, "testPattern": None}, + "phases": {"inmem": {"classes": 1, "methods": 10, "passed": 10, + "skipped": 0, "broken": 0, "flaky": 0, + "duration_s": 5}}} + newer = json.loads(json.dumps(rec)) + newer["timestamp"] = "2026-08-13T21:00:00Z" + newer["phases"]["inmem"]["broken"] = 1 + import unittest.mock as mock + with mock.patch(__name__ + ".classify_diff", return_value=""): + chosen = aggregate([rec, newer], "a" * 40) + assert chosen["inmem"][1]["broken"] == 1, "newest must win, even when red" + partial = json.loads(json.dumps(rec)) + partial["scope"]["complete"] = False + with mock.patch(__name__ + ".classify_diff", return_value=""): + chosen = aggregate([partial], "a" * 40) + assert chosen == {}, "incomplete records must never qualify" + md, cov = render_markdown({}, "a" * 40) + assert "*missing*" in md + # Verify annotation only fires for "tests" diffs, not "clean" diffs + with mock.patch(__name__ + ".classify_diff", return_value="clean"): + chosen = aggregate([rec], "a" * 40) + md_clean, _ = render_markdown(chosen, "a" * 40) + assert "test/doc/tooling files changed" not in md_clean, \ + "annotation must NOT fire for clean diffs (docs-only)" + with mock.patch(__name__ + ".classify_diff", return_value="tests"): + chosen = aggregate([rec], "a" * 40) + md_tests, _ = render_markdown(chosen, "a" * 40) + assert "test/doc/tooling files changed" in md_tests, \ + "annotation MUST fire for test-only diffs" + # A gap-state (missing phases) must still write badges - the tool only + # reports, it never withholds output because the news is bad. + import tempfile + with mock.patch(__name__ + ".classify_diff", return_value=""): + gap_chosen = aggregate([rec], "a" * 40) # only "inmem" present, 4 missing + with tempfile.TemporaryDirectory() as tmp: + write_badges(gap_chosen, None, tmp) + with open(os.path.join(tmp, "tests.json")) as fh: + badge = json.load(fh) + assert badge["color"] == "red", "gap-state badge must be red" + assert "1/5" in badge["message"], "gap-state badge must show the shortfall" + # Marker section: updateReleaseReport.sh splices on these markers verbatim, + # so both must appear, and appear exactly once, in the rendered markdown. + assert md.count(MARK_START) == 1, "start marker must appear exactly once" + assert md.count(MARK_END) == 1, "end marker must appear exactly once" + assert md.index(MARK_START) < md.index(MARK_END), "start marker must precede end marker" + print("selftest OK") + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--selftest", action="store_true") + ap.add_argument("--target-commit") + ap.add_argument("--markdown-out") + ap.add_argument("--badges-dir") + args = ap.parse_args() + if args.selftest: + selftest() + return + if not args.target_commit: + ap.error("--target-commit is required") + records = load_records() + chosen = aggregate(records, args.target_commit) + md, cov = render_markdown(chosen, args.target_commit) + missing = [p for p in REQUIRED_PHASES if p not in chosen] + # "broken" looks at required phases only — a red optional (extension-module) + # phase is reported but does not affect the exit code + broken = sum(chosen[p][1]["broken"] for p in chosen if p in REQUIRED_PHASES) + has_gaps = missing or broken + print(md) + if args.markdown_out: + with open(args.markdown_out, "w") as fh: + fh.write(md) + # Badges are written whenever records were loadable at all (exit 0 or 1) - + # a red badge honestly reflects a gap-state, it's not withheld to keep the + # working tree clean. Only exit 3 (store unreachable) skips them, since + # there is nothing to render. + if args.badges_dir: + write_badges(chosen, cov, args.badges_dir) + if has_gaps: + print("REPORT: gaps found - missing=%s broken=%d" % (missing, broken), + file=sys.stderr) + sys.exit(1) + else: + print("REPORT: all required phases complete and green", file=sys.stderr) + + +if __name__ == "__main__": + main() diff --git a/scripts/test_results_record.py b/scripts/test_results_record.py new file mode 100644 index 000000000..84bf02a99 --- /dev/null +++ b/scripts/test_results_record.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Build one test-results record (JSON) from a finished runtests.sh log directory. + +Part of the decoupled test-results store (see docs/superpowers/specs/ +2026-08-13-test-results-store-design.md). stdlib only, bash-3.2-friendly CLI. +""" +import argparse +import datetime +import json +import os +import re +import sys + +SUMMARY_RE = re.compile( + r"Tests run: (\d+), Failures: (\d+), Errors: (\d+), Skipped: (\d+)," + r" Time elapsed: ([\d.,]+) s.*- in ([\w.$]+)") +# report-level totals are the LAST counter of each type in a jacoco XML +# (module -> package -> class counters come first, report totals last). +# Regex instead of xml.etree: no XXE surface, and jacoco's DOCTYPE would +# trip the stdlib parser anyway. +COUNTER_RE = re.compile( + r'') + + +def parse_logdir(logdir): + try: + names = sorted(os.listdir(logdir)) + except (FileNotFoundError, NotADirectoryError) as e: + print("error: no parsable class logs in %s" % logdir, file=sys.stderr) + sys.exit(2) + classes = methods = method_failed = class_broken = skipped = 0 + duration = 0.0 + for name in names: + if not name.endswith(".log") or name == "failed.txt": + continue + path = os.path.join(logdir, name) + last = None + with open(path, errors="replace") as fh: + for line in fh: + m = SUMMARY_RE.search(line) + if m: + last = m + classes += 1 + if last is None: + class_broken += 1 # no summary line at all: build/setup failure of the class + continue + run, fails, errs, skip, elapsed, _fqcn = last.groups() + methods += int(run) + method_failed += int(fails) + int(errs) + skipped += int(skip) + duration += float(elapsed.replace(",", "")) + if classes == 0: + return None + return {"classes": classes, "methods": methods, + "passed": methods - method_failed - skipped, "skipped": skipped, + "broken": method_failed + class_broken, "duration_s": int(duration)} + + +def parse_coverage(pairs): + cov = {} + for module, path in pairs: + entry = {} + with open(path, errors="replace") as fh: + for ctype, missed, covered in COUNTER_RE.findall(fh.read()): + total = int(missed) + int(covered) + # keep overwriting: the last counter per type is the report total + entry[ctype.lower()] = ( + round(int(covered) * 100.0 / total, 1) if total else 0.0) + cov[module] = entry + return cov or None + + +def build(args): + phase_stats = parse_logdir(args.logdir) + if phase_stats is None: + print("error: no parsable class logs in %s" % args.logdir, file=sys.stderr) + sys.exit(2) + if args.flaky: + phase_stats["flaky"] = args.flaky + # flaky tests ended green after retries; they are counted broken by the + # last-line rule only when they stayed red, so no correction needed here. + else: + phase_stats["flaky"] = 0 + complete = not (args.tags or args.test_pattern) + record = { + "schema": 1, + "commit": args.commit, + "branch": args.branch, + "timestamp": datetime.datetime.now(datetime.timezone.utc) + .strftime("%Y-%m-%dT%H:%M:%SZ"), + "runner": args.runner, + "scope": {"complete": complete, + "tags": args.tags or None, + "testPattern": args.test_pattern or None}, + "phases": {args.phase: phase_stats}, + } + if args.duration_s: + record["phases"][args.phase]["duration_s"] = args.duration_s + cov = parse_coverage(args.coverage_xml) + if cov: + record["coverage"] = cov + return record + + +SELFTEST_LOG = """\ +some noise +[INFO] Tests run: 9, Failures: 1, Errors: 0, Skipped: 1, Time elapsed: 117.69 s <<< FAILURE! - in de.caluga.test.Foo +retry noise +[INFO] Tests run: 9, Failures: 0, Errors: 0, Skipped: 1, Time elapsed: 90.10 s - in de.caluga.test.Foo +""" + +SELFTEST_COV = """ + + +""" + + +def selftest(): + import tempfile + with tempfile.TemporaryDirectory() as td: + with open(os.path.join(td, "de.caluga.test.Foo.log"), "w") as fh: + fh.write(SELFTEST_LOG) + with open(os.path.join(td, "de.caluga.test.Broken.log"), "w") as fh: + fh.write("compile error, no summary line\n") + stats = parse_logdir(td) + assert stats == {"classes": 2, "methods": 9, "passed": 8, "skipped": 1, + "broken": 1, "duration_s": 90}, stats + covf = os.path.join(td, "cov.xml") + with open(covf, "w") as fh: + fh.write(SELFTEST_COV) + cov = parse_coverage([("morphium-core", covf)]) + assert cov == {"morphium-core": {"line": 74.2, "branch": 61.8}}, cov + print("selftest OK") + + +class ArgumentParser(argparse.ArgumentParser): + """argparse exits 2 on usage errors by default, which collides with this + script's documented "exit 2 = nothing to publish" contract (see + parse_logdir/build above). Usage errors (missing/malformed args) are a + hard error in the caller, not a benign skip, so they must exit 1 + instead - only the intentional sys.exit(2) sites above mean "skip".""" + + def error(self, message): + self.print_usage(sys.stderr) + self.exit(1, "%s: error: %s\n" % (self.prog, message)) + + +def main(): + ap = ArgumentParser() + ap.add_argument("--selftest", action="store_true") + ap.add_argument("--logdir") + ap.add_argument("--phase") + ap.add_argument("--runner") + ap.add_argument("--commit") + ap.add_argument("--branch") + ap.add_argument("--tags") + ap.add_argument("--test-pattern") + ap.add_argument("--flaky", type=int, default=0) + ap.add_argument("--duration-s", type=int, default=0) + ap.add_argument("--coverage-xml", action="append", default=[], + type=lambda s: tuple(s.split("=", 1))) + args = ap.parse_args() + if args.selftest: + selftest() + return + for req in ("logdir", "phase", "runner", "commit", "branch"): + if not getattr(args, req): + ap.error("--%s is required" % req) + json.dump(build(args), sys.stdout, indent=2) + print() + + +if __name__ == "__main__": + main() diff --git a/scripts/updateReleaseReport.sh b/scripts/updateReleaseReport.sh new file mode 100755 index 000000000..0bb52ec51 --- /dev/null +++ b/scripts/updateReleaseReport.sh @@ -0,0 +1,262 @@ +#!/bin/bash +# Refresh a GitHub release's test-results section and the shields.io badges +# from the append-only `test-results` store - the "living report": whenever +# new results are published (see runtests.sh publish_test_results()), the +# release notes for the *previously released* tag should reflect them, not +# stay frozen at whatever the matrix looked like at release time. Mirrors the +# style of publishTestResults.sh (bash 3.2 compatible: no associative arrays, +# no `local` outside functions where avoidable). +# +# Badges are published FIRST and unconditionally (they only need git push +# rights to `origin`, not `gh`) - CI runners publishing results typically have +# git push but no gh auth, and the badges must still refresh in that case. The +# GitHub release notes section is a separate, independently-guarded step after +# it: it needs `gh` installed and authenticated, and an existing release for +# the tag; missing any of those only skips the notes step, never the badges. +# +# Entirely best-effort: every failure path warns to stderr and exits 0 - this +# is called opportunistically after every publish (runtests.sh) and must +# never turn a successful test-results publish into a failing script. +# +# Usage: updateReleaseReport.sh [--tag vX.Y.Z] [--dry-run] +set -eo pipefail + +REMOTE=origin +BRANCH=test-results +DRY_RUN=0 +TAG="" + +while [ $# -ne 0 ]; do + case "$1" in + --tag) TAG="$2"; shift 2 ;; + --dry-run) DRY_RUN=1; shift ;; + *) echo "unknown option: $1" >&2; exit 1 ;; + esac +done + +# Resolve the real repo root from this script's own location rather than the +# caller's CWD. Callers in CI phase workdirs (/tmp/morphium-phase-workdir-*) +# invoke this script through a symlink sitting in a non-git symlink farm - +# `dirname "$0"`/`pwd` alone stays inside that farm (pwd doesn't dereference +# symlinks in the path it walked through), so it has to be dereferenced +# (python3 os.path.realpath; bash 3.2 has no readlink -f) back to where the +# script file actually lives, i.e. inside the real checkout. +REPO_ROOT=$(python3 -c 'import os,sys; print(os.path.dirname(os.path.dirname(os.path.realpath(sys.argv[1]))))' "$0") +git -C "$REPO_ROOT" rev-parse --git-dir >/dev/null 2>&1 || { echo "error: cannot resolve real repo root from $0 (resolved: $REPO_ROOT)" >&2; exit 1; } +# All subsequent git calls in this script, AND test_report.py's subprocess +# git calls (which inherit the CWD, not just argv), must run against the real +# repo regardless of where the caller invoked us from - so cd there now. +cd "$REPO_ROOT" + +if [ -z "$TAG" ]; then + # pipefail-safe: grep finding no v* tag must not abort the script via set -e + TAG=$(git tag --sort=-creatordate | { grep '^v' || true; } | head -1) +fi +if [ -z "$TAG" ]; then + echo "warning: no v* tag found - nothing to update" >&2 + exit 0 +fi + +TAG_COMMIT=$(git rev-list -n 1 "$TAG" 2>/dev/null) || TAG_COMMIT="" +if [ -z "$TAG_COMMIT" ]; then + echo "warning: cannot resolve commit for tag $TAG - skipping" >&2 + exit 0 +fi + +WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/morphium-releasereport.XXXXXX") +trap 'rm -rf "$WORKDIR"' EXIT + +REPORT_MD="$WORKDIR/report.md" +BADGES_TMP="$WORKDIR/badges" + +report_status=0 +python3 "$REPO_ROOT/scripts/test_report.py" --target-commit "$TAG_COMMIT" \ + --markdown-out "$REPORT_MD" --badges-dir "$BADGES_TMP" >/dev/null 2>&1 || report_status=$? + +if [ "$report_status" -eq 3 ]; then + echo "warning: test-results store unreachable - skipping release report update" >&2 + exit 0 +elif [ "$report_status" -ne 0 ] && [ "$report_status" -ne 1 ]; then + echo "warning: test_report.py failed unexpectedly (exit $report_status) - skipping" >&2 + exit 0 +fi +# exit 0 or 1 both fine here - the report is informational, not a gate. + +# --- Badges (first, unconditional): publish badges/tests.json + +# badges/coverage.json into the test-results store branch, so the README +# badges (which point at raw.githubusercontent.com/.../test-results/badges/*) +# stay live. This needs only `git push` rights to $REMOTE, not `gh` - it must +# not be gated behind gh availability/auth. Same clone+push-retry pattern as +# publishTestResults.sh. +if [ ! -f "$BADGES_TMP/tests.json" ]; then + echo "warning: no tests.json badge produced - skipping badge publish" >&2 +else + if [ "$DRY_RUN" -eq 1 ]; then + echo "dry-run: would publish badges/tests.json to $REMOTE/$BRANCH:" + cat "$BADGES_TMP/tests.json" + echo + if [ -f "$BADGES_TMP/coverage.json" ]; then + echo "dry-run: would publish badges/coverage.json to $REMOTE/$BRANCH:" + cat "$BADGES_TMP/coverage.json" + echo + fi + else + BADGE_WORKDIR=$(mktemp -d "${TMPDIR:-/tmp}/morphium-badges.XXXXXX") + trap 'rm -rf "$WORKDIR" "$BADGE_WORKDIR"' EXIT + REMOTE_URL=$(git remote get-url "$REMOTE") + + if git ls-remote --exit-code --heads "$REMOTE_URL" "$BRANCH" >/dev/null 2>&1; then + git clone -q --depth 1 --branch "$BRANCH" "$REMOTE_URL" "$BADGE_WORKDIR/store" + else + git init -q "$BADGE_WORKDIR/store" + (cd "$BADGE_WORKDIR/store" \ + && git checkout -q --orphan "$BRANCH" \ + && git remote add "$REMOTE" "$REMOTE_URL" \ + && printf '%s\n' "# Morphium test results" "" \ + "Append-only store of test-run records. One JSON file per run, written by" \ + "scripts/publishTestResults.sh (see docs in the main branches). Do not edit." \ + > README.md \ + && git add README.md \ + && git -c user.name="${MORPHIUM_RESULTS_GIT_NAME:-morphium-test-results}" \ + -c user.email="${MORPHIUM_RESULTS_GIT_EMAIL:-test-results@morphium.invalid}" \ + commit -q -m "chore: bootstrap test-results store") + fi + + ( + cd "$BADGE_WORKDIR/store" + mkdir -p badges + cp "$BADGES_TMP/tests.json" badges/tests.json + git add badges/tests.json + if [ -f "$BADGES_TMP/coverage.json" ]; then + cp "$BADGES_TMP/coverage.json" badges/coverage.json + git add badges/coverage.json + fi + + if git diff --cached --quiet; then + echo "badges unchanged - nothing to publish" + else + # Build the success message from what was actually staged (`git add` + # above), not a hardcoded list - coverage.json is optional (only + # written when a phase record carries coverage data), so claiming it + # was published when it wasn't would be a lie in the log. + PUBLISHED_FILES=$(git diff --cached --name-only -- badges/ | paste -sd+ -) + # -c user.name/-c user.email give this commit an identity even on a + # fresh machine with no git user.* configured (hit for real on the CI + # testrunner: "fatal: empty ident name" silently dropped a publish) - + # env vars let a runner customize it, default is a neutral bot identity. + git -c user.name="${MORPHIUM_RESULTS_GIT_NAME:-morphium-test-results}" \ + -c user.email="${MORPHIUM_RESULTS_GIT_EMAIL:-test-results@morphium.invalid}" \ + commit -q -m "badges: update for $TAG" + + n=0 + while ! git push -q "$REMOTE" "HEAD:refs/heads/$BRANCH" 2>/dev/null; do + n=$((n + 1)) + if [ "$n" -gt 5 ]; then + echo "warning: badge push failed after 5 retries" >&2 + exit 0 + fi + # non-fast-forward: someone else pushed (a results record); rebase + # our badge commit on top and retry + git fetch -q "$REMOTE" "$BRANCH" + git rebase -q "FETCH_HEAD" || { git rebase --abort; echo "warning: badge rebase failed" >&2; exit 0; } + done + echo "published $PUBLISHED_FILES to $REMOTE/$BRANCH for $TAG" + fi + ) + fi +fi + +# --- Owner-confirmed guard: if the aggregation found ZERO qualifying records +# for the tag's commit, the rendered table is all "*missing*" rows - don't +# touch the release notes in that case. Rationale: releases predating the +# test-results store (or any tag nobody has published results for yet) would +# otherwise get decorated with a permanently empty results table forever; +# better to leave the notes untouched and let the first real entry appear +# organically once qualifying runs actually exist. Badges are exempt from +# this guard (see above) since they reflect *current* state, not history. +# +# Detection: inspect the rendered markdown table rather than parsing +# test_report.py's stderr/exit code - exit 1 also fires for "gaps" where some +# (not all) required phases are missing, which must still update the notes, +# so the exit code alone can't distinguish "zero results" from "partial +# results". The markdown is the one artifact both this script and +# test_report.py agree on the shape of (see render_markdown()/selftest() in +# test_report.py), so it's the more robust signal. Exclude the header ("| +# Phase | ...") and the separator ("|---|...") structurally by their fixed +# literal prefixes rather than by the first data cell's letter case - phase +# names are free-form for optional/extension-module phases (e.g. a future +# "Jakarta-Data" row) and may start with an uppercase letter or digit, so a +# character-class match on the first cell would misclassify those as +# non-data rows. Any surviving "| ...|" row not containing "*missing*" means +# at least one phase qualified. +QUALIFYING_ROWS=$(grep '^| ' "$REPORT_MD" | grep -v '^| Phase ' | grep -v '^|---' | { grep -v '\*missing\*' || true; }) +if [ -z "$QUALIFYING_ROWS" ]; then + echo "info: no qualifying test results for $TAG - leaving release notes untouched" >&2 + exit 0 +fi + +# --- GitHub release notes (independently guarded): needs gh installed, +# authenticated, and an existing release for $TAG. Any of these missing only +# skips this section - the badges above have already been refreshed. +if ! command -v gh >/dev/null 2>&1; then + echo "warning: gh CLI not found - skipping release notes update" >&2 + exit 0 +fi +if ! gh auth status >/dev/null 2>&1; then + echo "warning: gh CLI not authenticated - skipping release notes update" >&2 + exit 0 +fi + +if ! gh release view "$TAG" >/dev/null 2>&1; then + echo "warning: no GitHub release for $TAG - skipping (release.sh creates it at release time)" >&2 + exit 0 +fi + +EXISTING_BODY=$(gh release view "$TAG" --json body -q .body) || { + echo "warning: failed to read existing release body for $TAG - skipping" >&2 + exit 0 +} +printf '%s' "$EXISTING_BODY" >"$WORKDIR/existing_body.txt" + +# Splice the marked section into the existing body: replace it in place if the +# markers are already present (re-publish - keeps the notes "living" without +# ever duplicating the section), otherwise append it. A safe python helper +# instead of sed because release notes are multiline and may contain +# characters sed would choke on. +NEW_BODY=$(python3 - "$WORKDIR/existing_body.txt" "$REPORT_MD" <<'PYEOF' +import sys + +MARK_START = "" +MARK_END = "" + +existing_path, section_path = sys.argv[1], sys.argv[2] +with open(existing_path) as fh: + existing = fh.read() +with open(section_path) as fh: + section = fh.read().rstrip("\n") + +start = existing.find(MARK_START) +end = existing.find(MARK_END) +if start != -1 and end != -1 and end > start: + end += len(MARK_END) + new_body = existing[:start] + section + existing[end:] +else: + existing_stripped = existing.rstrip("\n") + new_body = existing_stripped + "\n\n" + section if existing_stripped else section + +if not new_body.endswith("\n"): + new_body += "\n" +sys.stdout.write(new_body) +PYEOF +) + +if [ "$DRY_RUN" -eq 1 ]; then + echo "dry-run: would update GitHub release notes for $TAG with:" + printf '%s' "$NEW_BODY" +else + if ! printf '%s' "$NEW_BODY" | gh release edit "$TAG" --notes-file -; then + echo "warning: failed to update GitHub release notes for $TAG" >&2 + exit 0 + fi + echo "updated release notes for $TAG" +fi diff --git a/spring-boot-morphium/CHANGELOG.md b/spring-boot-morphium/CHANGELOG.md new file mode 100644 index 000000000..819a80033 --- /dev/null +++ b/spring-boot-morphium/CHANGELOG.md @@ -0,0 +1,37 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] - 1.0.0-SNAPSHOT + +### Added +- Spring Boot 3.4.13 auto-configuration for Morphium (`morphium.*` properties) +- Jakarta Data 1.0 repository support via JDK dynamic proxies + - `CrudRepository` and `MorphiumRepository` + - Query derivation: `findBy*`, `countBy*`, `existsBy*`, `deleteBy*` + - JDQL via `@Query` annotation + - `@Find` / `@Delete` with `@By` parameter binding + - Pagination (`Page`, `CursoredPage`, `PageRequest`) + - Sorting (`Sort`, `Order`, `@OrderBy`) + - Stream and async (`Stream`, `CompletionStage`) return types +- `@EnableMorphiumRepositories` annotation for repository scanning +- `@MorphiumTransactional` AOP aspect for declarative transactions +- Actuator health indicator (`/actuator/health` with Morphium connection details) +- `@MorphiumTest` composite test annotation (InMemDriver, no MongoDB required) +- Connection retry logic with linear backoff for transient failures +- SSL/TLS support via `morphium.ssl.*` properties + +### Changed +- Renamed Maven artifacts to follow the Spring Boot starter naming convention + (`-spring-boot-*`, the `spring-boot-` prefix being reserved for Spring's + own starters): `spring-boot-morphium-parent` → `morphium-spring-boot-parent`, + `spring-boot-morphium-autoconfigure` → `morphium-spring-boot-autoconfigure`, + `spring-boot-morphium-starter` → `morphium-spring-boot-starter`, + `spring-boot-morphium-test` → `morphium-spring-boot-test`. `groupId` (`de.caluga`) + and Java package names (`de.caluga.morphium.spring.*`) are unchanged. +- Renamed the `@ConfigurationProperties` prefix from `spring.morphium.*` to + `morphium.*` -- the `spring.*` namespace is reserved for Spring Boot's own + configuration keys. + diff --git a/spring-boot-morphium/README.md b/spring-boot-morphium/README.md new file mode 100644 index 000000000..926b2acdf --- /dev/null +++ b/spring-boot-morphium/README.md @@ -0,0 +1,408 @@ +# Morphium Spring Boot Starter + +[![Build](https://github.com/Bardioc1977/spring-boot-morphium/actions/workflows/build.yml/badge.svg)](https://github.com/Bardioc1977/spring-boot-morphium/actions/workflows/build.yml) +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) +[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.4.13-brightgreen)](https://spring.io/projects/spring-boot) +[![Java](https://img.shields.io/badge/Java-21%2B-orange)](https://adoptium.net) +[![Jakarta Data](https://img.shields.io/badge/Jakarta%20Data-1.0-green)](https://jakarta.ee/specifications/data/1.0/) + +A [Spring Boot](https://spring.io/projects/spring-boot) auto-configuration for +[Morphium](https://github.com/sboesebeck/morphium), an actively maintained MongoDB ORM +for Java -- with full **Jakarta Data 1.0** repository support. + +> **Part of the Morphium project.** This module is being integrated into the main +> [Morphium](https://github.com/sboesebeck/morphium) reactor and is versioned and +> released **in lockstep with Morphium** -- there is no separate release cadence or +> version line to track. Building the Morphium reactor builds this module against the +> exact Morphium core version in the same build. Maven coordinates are +> `morphium-spring-boot-starter` / `morphium-spring-boot-autoconfigure` / +> `morphium-spring-boot-test` (not `spring-boot-morphium-*` -- that naming was used +> before integration; see [MIGRATION-NOTES.md](MIGRATION-NOTES.md) for the full +> rename history). + +> **Companion project:** See [quarkus-morphium](https://github.com/Bardioc1977/quarkus-morphium) +> for Quarkus integration with the same Jakarta Data feature set. + +--- + +## Features + +- **Auto-configuration** -- `Morphium` bean created from `morphium.*` properties +- **Jakarta Data repositories** -- `@Repository` interfaces with JDK dynamic proxies (runtime) +- **Query derivation** -- `findBy*`, `countBy*`, `existsBy*`, `deleteBy*` with And/Or, Between, In, Like, etc. +- **JDQL** -- `@Query("WHERE status = :s ORDER BY name")` Jakarta Data Query Language +- **@Find / @Delete** -- explicit field binding via `@By` parameters +- **Transactions** -- `@MorphiumTransactional` with AOP-based commit/rollback +- **Actuator health** -- Morphium connection status in `/actuator/health` +- **Test support** -- `@MorphiumTest` composite annotation with InMemDriver (no MongoDB needed) +- **MorphiumRepository** -- escape hatch for `distinct()`, `query()`, `morphium()` access + +--- + +## Prerequisites + +| Dependency | Minimum version | +|---|---| +| Java | 21 | +| Spring Boot | 3.4.x | +| Morphium | 6.2.2 ([sboesebeck/morphium](https://github.com/sboesebeck/morphium)) | + +## Installation + +Add the starter to your `pom.xml`: + +```xml + + de.caluga + morphium-spring-boot-starter + 6.3.2-SNAPSHOT + +``` + +In the Morphium reactor, `${project.version}` currently resolves to `6.3.2-SNAPSHOT`. +This module follows Morphium's regular release versioning -- there is no independent +version to pin beyond the reactor version. + +> **Note:** Until published to Maven Central, build the reactor locally: +> ```bash +> git clone https://github.com/sboesebeck/morphium.git +> cd morphium +> mvn install -DskipTests +> ``` + +## Quick Start + +### 1. Configure + +```properties +# application.properties +morphium.database=my-database +morphium.hosts=localhost:27017 +``` + +### 2. Define an entity + +```java +@Entity(collectionName = "products") +public class Product { + @Id private MorphiumId id; + private String name; + private double price; + private String category; + + // getters, setters, constructors +} +``` + +### 3. Create a repository + +```java +@Repository +public interface ProductRepository extends MorphiumRepository { + + List findByCategory(String category); + + List findByPriceGreaterThan(double minPrice); + + long countByCategory(String category); + + @Query("WHERE category = :cat AND price > :minPrice ORDER BY price") + List findExpensive(@Param("cat") String category, + @Param("minPrice") double minPrice); +} +``` + +### 4. Enable and inject + +```java +@SpringBootApplication +@EnableMorphiumRepositories +public class MyApplication { + public static void main(String[] args) { + SpringApplication.run(MyApplication.class, args); + } +} +``` + +```java +@Service +public class ProductService { + + @Autowired ProductRepository products; + + public List findExpensive(double minPrice) { + return products.findByPriceGreaterThan(minPrice); + } +} +``` + +--- + +## Jakarta Data Repository Support + +| Feature | Details | +|---------|---------| +| **CRUD** | `CrudRepository`, `MorphiumRepository` -- save, insert, update, delete, findById, findAll | +| **Query derivation** | `findBy`, `countBy`, `existsBy`, `deleteBy` with operators: Equals, Not, GreaterThan, LessThan, Between, In, NotIn, Like, StartsWith, EndsWith, Null, NotNull, True, False -- combined with And/Or | +| **@Find + @By** | Explicit field binding via parameter annotations | +| **@Query (JDQL)** | Jakarta Data Query Language with WHERE, ORDER BY, named parameters, BETWEEN, IN, LIKE, IS NULL, NOT, GROUP BY, HAVING, aggregates | +| **@OrderBy** | Static sort annotation on query methods | +| **Pagination** | `Page`, `PageRequest`, `CursoredPage` (keyset pagination) | +| **Sorting** | `Sort`, `Order` as method parameters | +| **Stream** | `Stream` return type for large result sets | +| **Async** | `CompletionStage` return type for non-blocking operations | + +### MorphiumRepository -- The Escape Hatch + +`MorphiumRepository` extends `CrudRepository` with Morphium-specific operations: + +```java +// Distinct values for a field +List categories = products.distinct("category"); + +// Direct access to the Morphium API +products.morphium().inc(product, "stock", 5); + +// Create a typed Morphium Query +Query q = products.query(); +q.f("price").gt(100).f("category").eq("electronics"); +``` + +--- + +## Configuration Reference + +Property prefix is `morphium` (not `spring.morphium`) -- the `spring.*` namespace is +reserved for Spring Boot's own configuration keys. Every property below is verified +directly against `MorphiumProperties.java`. + +| Property | Default | Description | Source | +|---|---|---|---| +| `morphium.database` | *(required)* | MongoDB database name | `MorphiumProperties.java:46` | +| `morphium.hosts` | `localhost:27017` | Comma-separated `host:port` list; ignored if `morphium.atlas-url` is set | `MorphiumProperties.java:39` | +| `morphium.username` | -- | MongoDB username; only applied together with `morphium.password` | `MorphiumProperties.java:52` | +| `morphium.password` | -- | MongoDB password | `MorphiumProperties.java:57` | +| `morphium.auth-database` | `admin` | Authentication database (`authSource`) | `MorphiumProperties.java:64` | +| `morphium.driver-name` | `PooledDriver` | `PooledDriver` (production) or `InMemDriver` (tests, no MongoDB needed) | `MorphiumProperties.java:71` | +| `morphium.read-preference` | `primary` | MongoDB read preference | `MorphiumProperties.java:77` | +| `morphium.max-connections` | `250` | Connection pool size | `MorphiumProperties.java:82` | +| `morphium.atlas-url` | -- | MongoDB Atlas SRV URL (overrides `morphium.hosts` when set) | `MorphiumProperties.java:89` | +| `morphium.replica-set-name` | -- | Replica set name (required for transactions) | `MorphiumProperties.java:97` | +| `morphium.connect-retries` | `5` | Connection attempts before giving up on transient failures (linear backoff, `attempt * 2000`ms) | `MorphiumProperties.java:106` | +| `morphium.index-check` | `CREATE_ON_STARTUP` | `CREATE_ON_STARTUP`, `WARN_ON_STARTUP`, `CREATE_ON_WRITE_NEW_COL`, `NO_CHECK` | `MorphiumProperties.java:115` | +| `morphium.cache.global-valid-time` | `5000` | Cache TTL in milliseconds | `MorphiumProperties.java:361` | +| `morphium.cache.read-cache-enabled` | `true` | Enable query result cache | `MorphiumProperties.java:368` | +| `morphium.ssl.enabled` | `false` | Enable TLS | `MorphiumProperties.java:418` | +| `morphium.ssl.keystore-path` | -- | Keystore path (JKS/PKCS12) for client-certificate TLS | `MorphiumProperties.java:426` | +| `morphium.ssl.keystore-password` | -- | Keystore password | `MorphiumProperties.java:431` | + +If `spring-boot-configuration-processor` is on the classpath (it is an optional +dependency of `morphium-spring-boot-autoconfigure`), every property above also appears +in `META-INF/spring-configuration-metadata.json`, giving IDEs autocompletion and +validation for `morphium.*` keys. + +## Transactions + +Requires a MongoDB replica set or Atlas. + +```java +@Service +public class OrderService { + + @Autowired Morphium morphium; + + @MorphiumTransactional + public void placeOrder(Order order, Payment payment) { + morphium.store(order); + morphium.store(payment); + // auto-commit on success, auto-rollback on exception + } +} +``` + +## Actuator Health + +When `spring-boot-actuator` is on the classpath, a Morphium health indicator is +automatically registered at `/actuator/health`: + +```json +{ + "status": "UP", + "components": { + "morphium": { + "status": "UP", + "details": { + "database": "my-database", + "driver": "PooledDriver", + "replicaSet": true, + "replicaSetName": "rs0" + } + } + } +} +``` + +## Testing + +### Option A: InMemDriver (no MongoDB required) + +```properties +# src/test/resources/application-test.properties +morphium.database=test +morphium.driver-name=InMemDriver +``` + +```java +@SpringBootTest +@ActiveProfiles("test") +@EnableMorphiumRepositories +class ProductRepositoryTest { + + @Autowired ProductRepository repository; + + @Test + void shouldFindByCategory() { + repository.save(new Product("Widget", 9.99, "tools")); + + var results = repository.findByCategory("tools"); + assertThat(results).hasSize(1); + assertThat(results.get(0).getName()).isEqualTo("Widget"); + } +} +``` + +### Option B: @MorphiumTest annotation + +The `morphium-spring-boot-test` module provides a composite annotation: + +```xml + + de.caluga + morphium-spring-boot-test + 6.3.2-SNAPSHOT + test + +``` + +```java +@MorphiumTest +@EnableMorphiumRepositories +class ProductRepositoryTest { + + @Autowired ProductRepository repository; + + @Test + void shouldFindByCategory() { + // InMemDriver is auto-configured + } +} +``` + +## Module Structure + +``` +spring-boot-morphium/ + morphium-spring-boot-autoconfigure/ Auto-configuration, repository proxy, AOP, health + morphium-spring-boot-starter/ Dependency-only POM (pull this in your app) + morphium-spring-boot-test/ @MorphiumTest annotation for test support +``` + +## Architecture + +This starter uses **JDK dynamic proxies** at runtime (the standard Spring Data pattern), +in contrast to the [quarkus-morphium](https://github.com/Bardioc1977/quarkus-morphium) +extension which uses **Gizmo bytecode generation** at build time. Concretely: a +repository interface annotated `@Repository` is discovered at Spring context-startup +time by `MorphiumRepositoryRegistrar`, which registers a `MorphiumRepositoryFactoryBean` +that creates a `java.lang.reflect.Proxy` implementing the interface -- no implementation +class is ever generated or compiled. Quarkus instead generates a real, compiled +implementation class via Gizmo bytecode generation before the application starts, +avoiding runtime reflection entirely at the cost of a build-time processing step. + +Both share the same query engine via the +[morphium-jakarta-data](https://github.com/Bardioc1977/morphium-jakarta-data) module -- +a framework-agnostic library containing all Jakarta Data query derivation, JDQL parsing, +pagination, and CRUD logic. + +``` +morphium (core ODM) + └── morphium-jakarta-data (shared Jakarta Data runtime) + ├── morphium-spring-boot-* (this project, JDK proxies) + └── quarkus-morphium (Gizmo bytecode, build-time) +``` + +### Relationship to `morphium-jakarta-data` + +`morphium-jakarta-data` contains the entire framework-agnostic Jakarta Data runtime: +`MethodNameParser`/`QueryExecutor` (query derivation from method names), `JdqlParser`/ +`JdqlMethodBridge` (the `@Query` JDQL grammar), `FindMethodBridge` (`@Find`/`@Delete` +with `@By` parameter binding), pagination (`AbstractMorphiumRepository`'s offset and +cursor pagination), and sorting. None of that logic is duplicated here. + +This module (`morphium-spring-boot-*`) adds exactly the Spring-specific wiring on top: +the `Morphium` bean and `MorphiumProperties` (`morphium.*` configuration, +`MorphiumAutoConfiguration`), the `@EnableMorphiumRepositories`/ +`MorphiumRepositoryRegistrar`/`MorphiumRepositoryFactoryBean` JDK-proxy mechanism that +turns a `@Repository` interface into a Spring bean, the `@MorphiumTransactional` AOP +aspect, and the actuator health indicator. Every Jakarta Data feature documented for +`morphium-jakarta-data` (query derivation keywords, JDQL grammar, pagination types, +return-type handling) applies unchanged once wired through this module -- there is no +separate, Spring-specific feature set to learn. + +### Distinction from Spring Data MongoDB + +This module is **not** a replacement for or a re-implementation of Spring Data +MongoDB, and does not aim to be API-compatible with it: + +- It implements the **Jakarta Data 1.0** specification (`@Repository`, + `CrudRepository`, `@Find`, `@Query`/JDQL, `Page`/`CursoredPage`, `Sort`/`Order`), a + vendor-neutral Jakarta EE specification -- not Spring Data's own repository + interfaces (`MongoRepository`, `@Query` with a different string syntax, Spring + Data's `Criteria`/`Aggregation` API, etc.). +- The underlying data access is always **Morphium**, not Spring Data MongoDB's own + `MongoTemplate`/`MongoOperations`. There is no `MongoTemplate` bean and no + Spring Data MongoDB entity mapping (`@Document`, Spring Data converters) -- + entities use Morphium's own annotations (`@Entity`, `@Id`, `@Reference`, etc.). +- Transactions here are Morphium transactions (`Morphium.startTransaction()`/ + `commitTransaction()`/`abortTransaction()`) wrapped by a small AOP aspect, not + Spring's `PlatformTransactionManager`/`@Transactional` infrastructure. +- Query derivation, JDQL, and pagination/sorting behavior come from + `morphium-jakarta-data`; the exact keyword set and grammar there differs in detail + from Spring Data's query-method conventions (see that module's README/docs for the + full grammar), even though many method names look similar in simple cases + (`findByCategory`, `countByStatus`, ...). + +If your application already uses Spring Data MongoDB and does not use Morphium, this +module has nothing to offer you. If you are building on Morphium and want a +Spring-managed, dependency-injected repository layer with Jakarta Data semantics, this +is the module for that. + +## Building from Source + +```bash +# Part of the Morphium reactor -- build from the reactor root, or standalone with +# morphium and morphium-jakarta-data already installed to your local repository. + +mvn clean install + +# Run tests only +mvn test -pl morphium-spring-boot-autoconfigure +``` + +## Related Projects + +- [Morphium](https://github.com/sboesebeck/morphium) -- the underlying MongoDB ORM +- [morphium-jakarta-data](https://github.com/Bardioc1977/morphium-jakarta-data) -- shared Jakarta Data runtime +- [quarkus-morphium](https://github.com/Bardioc1977/quarkus-morphium) -- Quarkus CDI extension (same Jakarta Data features) +- [quarkus-morphium-showcase](https://github.com/Bardioc1977/quarkus-morphium-showcase) -- interactive demo +- [Jakarta Data 1.0](https://jakarta.ee/specifications/data/1.0/) -- the specification + +## Contributing + +Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. + +This project follows the [Contributor Covenant Code of Conduct](CODE_OF_CONDUCT.md). + +## License + +[Apache License 2.0](LICENSE) diff --git a/spring-boot-morphium/docs-for-morphium/spring-boot.md b/spring-boot-morphium/docs-for-morphium/spring-boot.md new file mode 100644 index 000000000..68986ce91 --- /dev/null +++ b/spring-boot-morphium/docs-for-morphium/spring-boot.md @@ -0,0 +1,307 @@ +# Spring Boot Starter: Auto-Configuration for Morphium + +`morphium-spring-boot-*` is an **optional Morphium module** that integrates Morphium +into [Spring Boot](https://spring.io/projects/spring-boot) applications via +auto-configuration, type-safe `@ConfigurationProperties`, declarative transactions, +an Actuator health indicator, and Jakarta Data `@Repository` interfaces backed by JDK +dynamic proxies at runtime — no build-time bytecode generation, no annotation +processor for the repositories themselves. It pulls in +[`morphium-jakarta-data`](jakarta-data.md) for the entire query-derivation, JDQL, and +pagination runtime. + +!!! note "Optional module — the Morphium core does not depend on it" + `de.caluga:morphium` has zero compile- or runtime dependency on this module, on + Spring, or on `jakarta.data-api`. You only need `morphium-spring-boot-starter` if + you are building a Spring Boot application against MongoDB via Morphium. + +## What it provides + +- **Auto-configuration** — `MorphiumAutoConfiguration` creates the application's + single `Morphium` bean from `morphium.*` properties, with connection retry on + transient failures (linear backoff). +- **Type-safe configuration** — every setting lives under `morphium.*` as + `@ConfigurationProperties`, with `spring-boot-configuration-processor`-generated + metadata for IDE autocompletion. +- **Jakarta Data repositories** — declare a `@Repository` interface extending + `CrudRepository`/`MorphiumRepository` from `morphium-jakarta-data`; at Spring + context-startup time, `MorphiumRepositoryRegistrar` scans for such interfaces and + registers a `MorphiumRepositoryFactoryBean` for each, which creates a + `java.lang.reflect.Proxy` implementing the interface — see + [Proxy mechanism vs. Quarkus](#proxy-mechanism-vs-quarkus) below. See + [Jakarta Data](jakarta-data.md) for the full query-derivation, JDQL, and pagination + feature set — everything documented there works identically once wired through this + module's proxies. +- **Declarative transactions** — `@MorphiumTransactional` on a Spring bean method + wraps the method body in `startTransaction()`/`commitTransaction()`/ + `abortTransaction()` via an AspectJ `@Around` advice, active only when + `spring-boot-starter-aop` is on the classpath. +- **Actuator health** — a `HealthIndicator` reporting live MongoDB connection status + (database, driver, replica-set state) under `/actuator/health`, active only when + `spring-boot-actuator` is present and a `Morphium` bean already exists. +- **Test support** — the companion `morphium-spring-boot-test` module provides + `@MorphiumTest`, a composite annotation that wires `InMemDriver` (Morphium's + in-memory MongoDB emulation) into a `@SpringBootTest`, so repository tests run + without a MongoDB instance or container. + +## Installation + +```xml + + de.caluga + morphium-spring-boot-starter + ${project.version} + +``` + +In the Morphium reactor, `${project.version}` currently resolves to `6.3.2-SNAPSHOT`. +This module follows Morphium's regular release versioning — it is versioned and +released in lockstep with Morphium; there is no separate version line to track. + +## Configuration Reference + +All properties live under `morphium.*` (not `spring.morphium.*` — the `spring.*` +namespace is reserved for Spring Boot's own configuration keys). Every entry below is +verified directly against `MorphiumProperties.java` in the +`morphium-spring-boot-autoconfigure` module. + +| Property | Default | Description | Source | +|---|---|---|---| +| `morphium.database` | *(required)* | MongoDB database name | `MorphiumProperties.java:46` | +| `morphium.hosts` | `localhost:27017` | Comma-separated `host:port` list; ignored if `morphium.atlas-url` is set | `MorphiumProperties.java:39` | +| `morphium.username` / `.password` | -- | Optional credentials, applied only when both are set | `MorphiumProperties.java:52,57` | +| `morphium.auth-database` | `admin` | Authentication database (`authSource`) | `MorphiumProperties.java:64` | +| `morphium.driver-name` | `PooledDriver` | `PooledDriver` (production) or `InMemDriver` (tests, no MongoDB needed) | `MorphiumProperties.java:71` | +| `morphium.read-preference` | `primary` | MongoDB read preference | `MorphiumProperties.java:77` | +| `morphium.max-connections` | `250` | Connection pool size | `MorphiumProperties.java:82` | +| `morphium.atlas-url` | -- | MongoDB Atlas SRV connection string (overrides `morphium.hosts` when set) | `MorphiumProperties.java:89` | +| `morphium.replica-set-name` | -- | Replica set name (required for transactions) | `MorphiumProperties.java:97` | +| `morphium.connect-retries` | `5` | Connection attempts before giving up on transient failures, linear backoff `attempt * 2000`ms | `MorphiumProperties.java:106` | +| `morphium.index-check` | `CREATE_ON_STARTUP` | `CREATE_ON_STARTUP`, `WARN_ON_STARTUP`, `CREATE_ON_WRITE_NEW_COL`, `NO_CHECK` | `MorphiumProperties.java:115` | +| `morphium.cache.global-valid-time` | `5000` | Cache TTL in milliseconds | `MorphiumProperties.java:361` | +| `morphium.cache.read-cache-enabled` | `true` | Enable query result cache | `MorphiumProperties.java:368` | +| `morphium.ssl.enabled` | `false` | Enable TLS | `MorphiumProperties.java:418` | +| `morphium.ssl.keystore-path` / `.keystore-password` | -- | Keystore (JKS/PKCS12) for client-certificate TLS | `MorphiumProperties.java:426,431` | + +If `spring-boot-configuration-processor` is on the classpath (declared as an optional +dependency of `morphium-spring-boot-autoconfigure`), every property above also appears +in `META-INF/spring-configuration-metadata.json`, giving IDEs autocompletion and +validation for `morphium.*` keys. + +## Quick Example + +```java +@Entity(collectionName = "products") +public class Product { + @Id private MorphiumId id; + private String name; + private double price; + private String category; + // getters/setters omitted +} + +@Repository +public interface ProductRepository extends MorphiumRepository { + List findByCategory(String category); + + List findByPriceGreaterThan(double minPrice); +} + +@SpringBootApplication +@EnableMorphiumRepositories +public class MyApplication { + public static void main(String[] args) { + SpringApplication.run(MyApplication.class, args); + } +} + +@Service +public class ProductService { + @Autowired ProductRepository products; + + public List findExpensive(double minPrice) { + return products.findByPriceGreaterThan(minPrice); + } +} +``` + +```properties +morphium.database=my-app-db +morphium.hosts=localhost:27017 +``` + +## Repository Usage + +Annotate a `@SpringBootApplication` (or any `@Configuration` class) with +`@EnableMorphiumRepositories` to enable scanning. By default the scan covers the +annotated class's package and sub-packages; pass explicit packages via `value()`/ +`basePackages()` to scan elsewhere. + +Repository interfaces extend either `jakarta.data.repository.CrudRepository` +(the plain Jakarta Data interface) or `de.caluga.morphium.data.MorphiumRepository`, which adds Morphium-specific escape hatches with no Jakarta Data equivalent: + +```java +// Distinct values for a field +List categories = products.distinct("category"); + +// Direct access to the Morphium API +products.morphium().inc(product, "stock", 5); + +// A typed Morphium Query, for anything beyond derived queries/JDQL/@Find +Query q = products.query(); +q.f("price").gt(100).f("category").eq("electronics"); +``` + +### Proxy mechanism vs. Quarkus + +This module uses **JDK dynamic proxies at runtime** — the standard Spring Data +pattern — in contrast to the [Quarkus extension](quarkus-extension.md), which uses +**Gizmo bytecode generation at build time**. + +Concretely: at Spring context-startup time, `MorphiumRepositoryRegistrar` (imported by +`@EnableMorphiumRepositories`) scans the configured base packages for `@Repository` +interfaces and registers a `MorphiumRepositoryFactoryBean` bean definition for each +one found. Each factory bean creates a `java.lang.reflect.Proxy` implementing the +repository interface, backed by a `MorphiumRepositoryInvocationHandler` that +dispatches every method call — derived queries, JDQL, `@Find`/`@Delete`, plain CRUD — +to the shared `morphium-jakarta-data` runtime. No implementation class is ever +generated or compiled; the proxy is synthesized by the JVM itself, once per repository +interface, the first time the bean is requested. + +Quarkus's `quarkus-morphium` extension instead runs a build-time processor that emits +a real, compiled implementation class via Gizmo bytecode generation before the +application ever starts — no proxy or reflective dispatch exists at runtime there at +all. The trade-off is the classic one: this module's proxies need zero build-time +tooling and work with plain `javac`, at the cost of a small amount of per-call +reflective dispatch overhead and no build-time validation of query derivation; +Quarkus's build-time generation avoids that runtime cost and validates earlier, at the +cost of requiring its build-time augmentation phase. Both mechanisms delegate to the +exact same `morphium-jakarta-data` query engine — only *how* a repository interface is +wired to that engine differs. + +## Transactions + +Requires a MongoDB replica set or Atlas cluster (`morphium.replica-set-name`) — a +standalone MongoDB node rejects multi-document transactions. + +```java +@Service +public class OrderService { + @Autowired Morphium morphium; + + @MorphiumTransactional + public void placeOrder(Order order, Payment payment) { + morphium.store(order); + morphium.store(payment); + // committed automatically on return, rolled back automatically on exception + } +} +``` + +`@MorphiumTransactional` is picked up by an AspectJ `@Around` advice +(`MorphiumTransactionAspect`) that is only active when `spring-boot-starter-aop` is on +the classpath and a `Morphium` bean exists in the context. It starts a transaction +before the advised method runs, commits on normal return, and aborts (rethrowing the +original exception unchanged) if the method throws. + +## Health / Actuator + +When `spring-boot-actuator` is on the classpath and a `Morphium` bean already exists, +`MorphiumHealthAutoConfiguration` registers a `HealthIndicator` under +`/actuator/health`: + +```json +{ + "components": { + "morphium": { + "status": "UP", + "details": { + "database": "my-app-db", + "driver": "PooledDriver", + "replicaSet": true, + "replicaSetName": "rs0" + } + } + } +} +``` + +Disable it with `management.health.morphium.enabled=false`, or override it entirely +by defining your own `@Bean(name = "morphiumHealthIndicator") HealthIndicator` — the +auto-configured bean backs off via `@ConditionalOnMissingBean(name = +"morphiumHealthIndicator")`. + +## Testing without a MongoDB instance + +```properties +# src/test/resources/application-test.properties +morphium.database=test +morphium.driver-name=InMemDriver +``` + +```java +@SpringBootTest +@ActiveProfiles("test") +@EnableMorphiumRepositories +class ProductRepositoryTest { + @Autowired ProductRepository repository; + + @Test + void shouldFindByCategory() { + repository.save(new Product("Widget", 9.99, "tools")); + assertThat(repository.findByCategory("tools")).hasSize(1); + } +} +``` + +The companion `morphium-spring-boot-test` module wraps the same properties into a +composite `@MorphiumTest` annotation: + +```java +@MorphiumTest +@EnableMorphiumRepositories +class ProductRepositoryTest { + @Autowired ProductRepository repository; + // InMemDriver is auto-configured — no MongoDB instance or container needed +} +``` + +`InMemDriver` is Morphium's in-memory MongoDB emulation — tests run against it with no +container and no external MongoDB, exactly like the core Morphium test suite. + +## Distinction from Spring Data MongoDB + +This module is **not** a replacement for, or a re-implementation of, Spring Data +MongoDB, and does not aim to be API-compatible with it: + +- It implements the **Jakarta Data 1.0** specification (`@Repository`, + `CrudRepository`, `@Find`, `@Query`/JDQL, `Page`/`CursoredPage`, `Sort`/`Order`) — a + vendor-neutral Jakarta EE specification — not Spring Data's own repository + interfaces or query-method conventions. +- The underlying data access is always **Morphium**, not Spring Data MongoDB's + `MongoTemplate`/`MongoOperations`. There is no `MongoTemplate` bean and no Spring + Data MongoDB entity mapping; entities use Morphium's own annotations (`@Entity`, + `@Id`, `@Reference`, etc.). +- Transactions are Morphium transactions wrapped by a small AOP aspect, not Spring's + `PlatformTransactionManager`/`@Transactional` infrastructure. +- Query derivation, JDQL, and pagination/sorting behavior come from + `morphium-jakarta-data`; the keyword set and grammar differ in detail from Spring + Data's query-method conventions, even though simple method names + (`findByCategory`, `countByStatus`, ...) often look similar. + +If your application already uses Spring Data MongoDB and does not use Morphium, this +module has nothing to offer you. If you are building on Morphium and want a +Spring-managed, dependency-injected repository layer with Jakarta Data semantics, this +is the module for that. + +## Full Documentation + +This page is an overview. The complete module documentation — installation, the full +property reference, repository usage, transactions, testing, and the detailed +architecture comparison with Quarkus — lives in the module's own README: + +[`spring-boot-morphium/README.md`](https://github.com/sboesebeck/morphium/blob/develop/spring-boot-morphium/README.md) + +See also [Jakarta Data](jakarta-data.md) for the framework-agnostic repository runtime +this module builds on, and [Quarkus Extension](quarkus-extension.md) for the +build-time-bytecode alternative to this module's runtime JDK proxies. diff --git a/spring-boot-morphium/morphium-spring-boot-autoconfigure/pom.xml b/spring-boot-morphium/morphium-spring-boot-autoconfigure/pom.xml new file mode 100644 index 000000000..1939524e9 --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-autoconfigure/pom.xml @@ -0,0 +1,103 @@ + + + 4.0.0 + + + de.caluga + morphium-spring-boot-parent + 6.3.2-SNAPSHOT + + + morphium-spring-boot-autoconfigure + Morphium Spring Boot – Autoconfigure + + + + org.springframework.boot + spring-boot-autoconfigure + + + org.springframework.boot + spring-boot-starter-aop + true + + + org.springframework.boot + spring-boot-actuator-autoconfigure + true + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + de.caluga + morphium + ${project.version} + + + de.caluga + morphium-jakarta-data + ${project.version} + + + jakarta.data + jakarta.data-api + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + + org.apache.maven.plugins + maven-source-plugin + + + org.apache.maven.plugins + maven-javadoc-plugin + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.springframework.boot + spring-boot-configuration-processor + ${spring-boot.version} + + + + + + + diff --git a/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/EnableMorphiumRepositories.java b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/EnableMorphiumRepositories.java new file mode 100644 index 000000000..954f68851 --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/EnableMorphiumRepositories.java @@ -0,0 +1,86 @@ +package de.caluga.morphium.spring.autoconfigure; + +import org.springframework.context.annotation.Import; + +import java.lang.annotation.*; + +/** + * Enables scanning for Jakarta Data {@code @Repository} interfaces (extending + * {@code jakarta.data.repository.CrudRepository} or + * {@code de.caluga.morphium.data.MorphiumRepository}) and registers a Spring bean for + * each one found, backed by a JDK dynamic proxy. + * + *

    By default the scan covers the package of the class annotated with + * {@code @EnableMorphiumRepositories} and its sub-packages; pass explicit packages via + * {@link #value()} or {@link #basePackages()} to scan elsewhere. + * + *

    {@code
    + * @SpringBootApplication
    + * @EnableMorphiumRepositories
    + * public class MyApplication {
    + *     public static void main(String[] args) {
    + *         SpringApplication.run(MyApplication.class, args);
    + *     }
    + * }
    + * }
    + * + *
    {@code
    + * @Repository
    + * public interface ProductRepository extends MorphiumRepository {
    + *     List findByCategory(String category);
    + * }
    + *
    + * @Service
    + * public class ProductService {
    + *     @Autowired ProductRepository products; // JDK proxy, injected like any bean
    + * }
    + * }
    + * + *

    How the proxy mechanism works

    + * This annotation triggers {@code @Import(MorphiumRepositoryRegistrar.class)}. At + * context-startup time, {@link MorphiumRepositoryRegistrar} scans the configured + * base packages for {@code @Repository} interfaces and registers one + * {@link MorphiumRepositoryFactoryBean} bean definition per interface found. Each + * {@code FactoryBean} creates a {@link java.lang.reflect.Proxy JDK dynamic proxy} + * implementing the repository interface, backed by a + * {@link MorphiumRepositoryInvocationHandler} that dispatches every method call — + * derived queries ({@code findBy*}), {@code @Query} (JDQL), {@code @Find}/{@code + * @Delete}, and plain CRUD — to the shared, framework-agnostic runtime in + * {@code morphium-jakarta-data}. No implementation class is ever generated or + * compiled; the interface's bytecode is used unmodified, and Java's built-in + * {@code java.lang.reflect.Proxy} mechanism creates the implementing class + * at application startup, in the running JVM. + * + *

    This is deliberately different from the {@code quarkus-morphium} extension's + * approach to the same problem: Quarkus generates a concrete implementation class for + * each repository interface via Gizmo bytecode generation at build + * time, so no proxy or reflection exists at runtime at all — the generated + * class is compiled into the application the same as any other class. The trade-off + * is the classic one between the two approaches: this module's JDK proxies need zero + * build-time tooling and work unmodified with plain {@code javac}, at the cost of a + * small amount of reflective dispatch overhead per repository call and no build-time + * validation of query derivation; Quarkus's build-time generation shifts that + * validation earlier and avoids the runtime dispatch cost, at the cost of requiring + * its build-time augmentation phase. Both approaches share the same query engine + * (query derivation, JDQL parsing, pagination, CRUD) via {@code morphium-jakarta-data} + * — only the mechanism that wires a repository interface to that engine differs. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Import(MorphiumRepositoryRegistrar.class) +public @interface EnableMorphiumRepositories { + + /** + * Base packages to scan for {@code @Repository} interfaces. Equivalent to + * {@link #basePackages()} — both arrays are merged if both are given. Defaults to + * an empty array, in which case the package of the annotated class is scanned. + */ + String[] value() default {}; + + /** + * Alias for {@link #value()}, provided for readability when only base packages + * (and no other attribute) are specified. + */ + String[] basePackages() default {}; +} diff --git a/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumAutoConfiguration.java b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumAutoConfiguration.java new file mode 100644 index 000000000..c5d3268cf --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumAutoConfiguration.java @@ -0,0 +1,237 @@ +package de.caluga.morphium.spring.autoconfigure; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.MorphiumConfig; +import de.caluga.morphium.config.CollectionCheckSettings; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; + +/** + * Auto-configuration that creates the application's single {@link Morphium} bean from + * {@link MorphiumProperties} ({@code morphium.*} keys). It applies only when + * {@code de.caluga.morphium.Morphium} is on the classpath + * ({@code @ConditionalOnClass(Morphium.class)}); on a plain Spring Boot application + * with the {@code morphium-spring-boot-starter} dependency, this is always the case. + * + *

    Adding the starter and configuring at least {@code morphium.database} is enough + * to get a connected, injectable {@code Morphium} instance: + * + *

    {@code
    + * // application.properties
    + * morphium.database=my-database
    + * morphium.hosts=localhost:27017
    + * }
    + * + *
    {@code
    + * @Service
    + * public class ProductService {
    + *     @Autowired Morphium morphium;
    + * }
    + * }
    + * + *

    Overriding the {@code Morphium} bean

    + * The {@link #morphium(MorphiumProperties)} bean method is annotated + * {@code @ConditionalOnMissingBean}: if the application context already defines its + * own {@code Morphium} bean (of any name), this auto-configuration backs off entirely + * and its bean method is never invoked. This is the standard Spring Boot + * "auto-configuration as a default, not a mandate" pattern — define your own + * {@code @Bean Morphium morphium(...)} to take full control of connection setup while + * still using every other part of this module ({@link EnableMorphiumRepositories}, + * {@link MorphiumTransactional}, the actuator health indicator). + * + *

    Connection retries: transient failures during the initial connection attempt + * (MongoDB not yet electing a primary, or not yet accepting connections) are retried + * up to {@link MorphiumProperties#getConnectRetries()} times with a linear backoff of + * {@code attempt * 2000} milliseconds; non-transient failures propagate on the first + * attempt. + */ +@AutoConfiguration +@ConditionalOnClass(Morphium.class) +@EnableConfigurationProperties(MorphiumProperties.class) +public class MorphiumAutoConfiguration { + + private static final Logger log = LoggerFactory.getLogger(MorphiumAutoConfiguration.class); + + /** + * Builds and connects the application's {@link Morphium} instance from + * {@code properties}: it builds a {@code MorphiumConfig} from {@code properties} + * (see {@link #buildConfig}) and connects with retry (see {@link #connectWithRetry}). + * + *

    Only runs if no other {@code Morphium} bean is already defined in the context + * ({@code @ConditionalOnMissingBean}) — see the class-level documentation for how + * to supply your own. + * + * @param properties the bound {@code morphium.*} configuration + * @return a connected {@code Morphium} instance, ready for injection + * @throws RuntimeException (or a Morphium-specific subtype) if the connection + * cannot be established within {@link MorphiumProperties#getConnectRetries()} + * attempts, or if building an SSL context from + * {@link MorphiumProperties.SslProperties} fails + */ + @Bean + @ConditionalOnMissingBean + public Morphium morphium(MorphiumProperties properties) { + MorphiumConfig cfg = buildConfig(properties); + Morphium m = connectWithRetry(cfg, properties.getConnectRetries()); + + if (properties.getReplicaSetName() != null && !m.getDriver().isReplicaSet()) { + log.debug("Forcing replicaSet=true on driver (single-node replica set workaround)"); + m.getDriver().setReplicaSet(true); + } + + log.info("Morphium connected to '{}' (driver: {}, replicaSet: {})", + properties.getDatabase(), properties.getDriverName(), + m.getDriver().isReplicaSet()); + + return m; + } + + /** + * Translates every {@link MorphiumProperties} field into the corresponding + * {@code MorphiumConfig} setting: database, driver name, connection pool size, + * read preference, index-check mode, host list (or Atlas URL if configured, which + * then takes precedence over the host list), replica set name, credentials, cache + * settings, and — if {@code morphium.ssl.enabled} is {@code true} — an SSL context + * built from the configured keystore. + * + * @param properties the bound {@code morphium.*} configuration + * @return a fully populated {@code MorphiumConfig}, not yet connected + * @throws IllegalStateException if {@code morphium.ssl.enabled} is {@code true} + * and building the {@code SSLContext} from the configured keystore fails + */ + private MorphiumConfig buildConfig(MorphiumProperties properties) { + MorphiumConfig cfg = new MorphiumConfig(); + + cfg.connectionSettings().setDatabase(properties.getDatabase()); + cfg.driverSettings().setDriverName(properties.getDriverName()); + cfg.connectionSettings().setMaxConnections(properties.getMaxConnections()); + cfg.driverSettings().setDefaultReadPreferenceType(properties.getReadPreference()); + + // Index check mode + switch (properties.getIndexCheck()) { + case "CREATE_ON_STARTUP": + cfg.collectionCheckSettings().setIndexCheck(CollectionCheckSettings.IndexCheck.CREATE_ON_STARTUP); + break; + case "WARN_ON_STARTUP": + cfg.collectionCheckSettings().setIndexCheck(CollectionCheckSettings.IndexCheck.WARN_ON_STARTUP); + break; + case "CREATE_ON_WRITE_NEW_COL": + cfg.setAutoIndexAndCappedCreationOnWrite(true); + break; + case "NO_CHECK": + cfg.collectionCheckSettings().setIndexCheck(CollectionCheckSettings.IndexCheck.NO_CHECK); + break; + } + + // Host configuration + if (properties.getAtlasUrl() != null && !properties.getAtlasUrl().isBlank()) { + cfg.clusterSettings().setAtlasUrl(properties.getAtlasUrl()); + } else { + for (String host : properties.getHosts()) { + String trimmed = host.trim(); + if (!trimmed.isEmpty()) { + cfg.clusterSettings().addHostToSeed(trimmed); + } + } + } + + // Replica set name + if (properties.getReplicaSetName() != null && !properties.getReplicaSetName().isBlank()) { + cfg.clusterSettings().setRequiredReplicaSetName(properties.getReplicaSetName()); + } + + // Credentials + if (properties.getUsername() != null && properties.getPassword() != null) { + cfg.authSettings().setMongoLogin(properties.getUsername()); + cfg.authSettings().setMongoPassword(properties.getPassword()); + cfg.authSettings().setMongoAuthDb(properties.getAuthDatabase()); + } + + // Cache + cfg.cacheSettings().setGlobalCacheValidTime(properties.getCache().getGlobalValidTime()); + cfg.cacheSettings().setReadCacheEnabled(properties.getCache().isReadCacheEnabled()); + + // SSL + if (properties.getSsl().isEnabled()) { + cfg.setUseSSL(true); + String keystorePath = properties.getSsl().getKeystorePath(); + String keystorePassword = properties.getSsl().getKeystorePassword(); + if (keystorePath != null) { + try { + var sslContext = de.caluga.morphium.driver.wire.SslHelper.createSslContext( + keystorePath, keystorePassword, null, null); + cfg.setSslContext(sslContext); + } catch (Exception e) { + throw new IllegalStateException("Failed to build SSLContext: " + e.getMessage(), e); + } + } + } + + return cfg; + } + + /** + * Attempts to construct a connected {@link Morphium} instance from {@code cfg}, + * retrying up to {@code maxRetries} times (at least once, regardless of the value + * passed) when {@link #isTransient(Throwable)} recognizes the failure as + * transient. Each retry waits {@code attempt * 2000} milliseconds before trying + * again. + * + * @param cfg the configuration to connect with + * @param maxRetries maximum number of connection attempts; values less than 1 are + * treated as 1 + * @return a connected {@code Morphium} instance + * @throws RuntimeException the original exception from the last attempt, if every + * attempt failed with a transient error, or immediately if an attempt + * failed with a non-transient error + * @throws IllegalStateException never thrown in practice — present only to satisfy + * the compiler after the retry loop, which always returns or throws + */ + private Morphium connectWithRetry(MorphiumConfig cfg, int maxRetries) { + int maxAttempts = Math.max(1, maxRetries); + for (int attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return new Morphium(cfg); + } catch (Exception e) { + if (!isTransient(e) || attempt == maxAttempts) { + throw e; + } + long delayMs = attempt * 2000L; + log.warn("Morphium connection attempt {}/{} failed: {}. Retrying in {}ms...", + attempt, maxAttempts, e.getMessage(), delayMs); + try { + Thread.sleep(delayMs); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while retrying Morphium connection", ie); + } + } + } + throw new IllegalStateException("Unreachable"); + } + + /** + * Walks the exception's cause chain looking for messages that indicate a + * transient MongoDB connection state ("No primary node found", "not connected + * yet") rather than a permanent configuration or authentication error. + * + * @param t the throwable raised while connecting + * @return {@code true} if any exception in the cause chain matches a known + * transient-failure message + */ + private static boolean isTransient(Throwable t) { + while (t != null) { + String msg = t.getMessage(); + if (msg != null && (msg.contains("No primary node found") || msg.contains("not connected yet"))) { + return true; + } + t = t.getCause(); + } + return false; + } +} diff --git a/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumHealthAutoConfiguration.java b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumHealthAutoConfiguration.java new file mode 100644 index 000000000..a961701d5 --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumHealthAutoConfiguration.java @@ -0,0 +1,98 @@ +package de.caluga.morphium.spring.autoconfigure; + +import de.caluga.morphium.Morphium; +import org.springframework.boot.actuate.autoconfigure.health.ConditionalOnEnabledHealthIndicator; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +/** + * Auto-configuration that registers a Spring Boot Actuator {@link HealthIndicator} + * reporting the connection status of the application's {@link Morphium} bean under + * {@code /actuator/health}. It runs after {@link MorphiumAutoConfiguration} + * ({@code @AutoConfiguration(after = MorphiumAutoConfiguration.class)}) and applies + * only when all of the following hold: + *

      + *
    • {@code org.springframework.boot.actuate.health.HealthIndicator} is on the + * classpath ({@code @ConditionalOnClass}) — i.e. {@code spring-boot-actuator} + * is present;
    • + *
    • a {@link Morphium} bean already exists in the context + * ({@code @ConditionalOnBean}) — there is nothing to report on otherwise.
    • + *
    + * + *

    With both conditions met and no further configuration, {@code /actuator/health} + * includes: + * + *

    {@code
    + * {
    + *   "components": {
    + *     "morphium": {
    + *       "status": "UP",
    + *       "details": {
    + *         "database": "my-database",
    + *         "driver": "PooledDriver",
    + *         "replicaSet": true,
    + *         "replicaSetName": "rs0"
    + *       }
    + *     }
    + *   }
    + * }
    + * }
    + * + *

    Disabling or overriding the indicator

    + * The bean method is additionally guarded by + * {@code @ConditionalOnEnabledHealthIndicator("morphium")} — set + * {@code management.health.morphium.enabled=false} to turn it off entirely — and by + * {@code @ConditionalOnMissingBean(name = "morphiumHealthIndicator")}, so defining + * your own {@code @Bean(name = "morphiumHealthIndicator") HealthIndicator} in the + * application context takes precedence over the auto-configured one. + */ +@AutoConfiguration(after = MorphiumAutoConfiguration.class) +@ConditionalOnClass(HealthIndicator.class) +@ConditionalOnBean(Morphium.class) +public class MorphiumHealthAutoConfiguration { + + /** + * Builds the {@code morphium} health indicator. On each invocation it checks + * {@code morphium.getDriver().isConnected()} and reports {@code UP}/{@code DOWN} + * accordingly, attaching the configured database name, driver name, and replica + * set status/name as detail fields. Any exception thrown while querying the + * driver is caught and reported as {@code DOWN} with the exception attached. + * + *

    Guarded by {@code @ConditionalOnEnabledHealthIndicator("morphium")} (respects + * {@code management.health.morphium.enabled}) and + * {@code @ConditionalOnMissingBean(name = "morphiumHealthIndicator")} so a + * user-defined bean of the same name overrides this one instead of colliding with + * it. + * + * @param morphium the application's {@link Morphium} bean, guaranteed present by + * {@code @ConditionalOnBean(Morphium.class)} on the class + * @return a {@link HealthIndicator} reporting live connection status on every + * health check invocation + */ + @Bean + @ConditionalOnEnabledHealthIndicator("morphium") + @ConditionalOnMissingBean(name = "morphiumHealthIndicator") + public HealthIndicator morphiumHealthIndicator(Morphium morphium) { + return () -> { + try { + var driver = morphium.getDriver(); + boolean connected = driver.isConnected(); + var builder = connected ? Health.up() : Health.down(); + builder.withDetail("database", morphium.getConfig().connectionSettings().getDatabase()); + builder.withDetail("driver", morphium.getConfig().driverSettings().getDriverName()); + builder.withDetail("replicaSet", driver.isReplicaSet()); + if (driver.getReplicaSetName() != null) { + builder.withDetail("replicaSetName", driver.getReplicaSetName()); + } + return builder.build(); + } catch (Exception e) { + return Health.down(e).build(); + } + }; + } +} diff --git a/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumProperties.java b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumProperties.java new file mode 100644 index 000000000..358d688f3 --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumProperties.java @@ -0,0 +1,478 @@ +package de.caluga.morphium.spring.autoconfigure; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.List; + +/** + * Binds every {@code morphium.*} key from {@code application.properties}/{@code .yml} + * to a {@link de.caluga.morphium.MorphiumConfig} that {@link MorphiumAutoConfiguration} + * uses to build the {@link de.caluga.morphium.Morphium} bean. Registered via + * {@code @EnableConfigurationProperties(MorphiumProperties.class)} on + * {@link MorphiumAutoConfiguration}, so it is only active together with that + * auto-configuration (i.e. when {@code de.caluga.morphium.Morphium} is on the + * classpath). + * + *

    The property prefix is {@code morphium} (not {@code spring.morphium}) — the + * {@code spring.*} namespace is reserved for Spring Boot's own configuration keys. + * A minimal configuration only needs the database name and, unless the default + * applies, the host list: + * + *

    {@code
    + * morphium.database=my-database
    + * morphium.hosts=localhost:27017
    + * }
    + * + *

    If {@code spring-boot-configuration-processor} is on the classpath (it is an + * optional dependency of this module), every field below also appears in + * {@code META-INF/spring-configuration-metadata.json}, giving IDEs autocompletion + * and validation for {@code morphium.*} keys. + */ +@ConfigurationProperties(prefix = "morphium") +public class MorphiumProperties { + + /** + * Comma-separated {@code host:port} list of MongoDB seed nodes, used unless + * {@link #atlasUrl} is set (in which case {@link #atlasUrl} takes precedence). + * Default: {@code localhost:27017}. + */ + private List hosts = List.of("localhost:27017"); + + /** + * Name of the MongoDB database Morphium connects to. Required — there is no + * default; {@link MorphiumAutoConfiguration} passes this straight to + * {@code MorphiumConfig.connectionSettings().setDatabase(...)}. + */ + private String database; + + /** + * MongoDB username. Only applied if both {@link #username} and {@link #password} + * are non-null; no default (unset means no authentication). + */ + private String username; + + /** + * MongoDB password, applied together with {@link #username}. No default. + */ + private String password; + + /** + * Database against which {@link #username}/{@link #password} are authenticated + * (MongoDB's {@code authSource}). Default: {@code admin}. Ignored unless + * {@link #username} and {@link #password} are both set. + */ + private String authDatabase = "admin"; + + /** + * Name of the Morphium driver implementation to use, e.g. {@code PooledDriver} + * for production against a real MongoDB, or {@code InMemDriver} for tests that + * run without a MongoDB instance. Default: {@code PooledDriver}. + */ + private String driverName = "PooledDriver"; + + /** + * MongoDB read preference applied to the driver (e.g. {@code primary}, + * {@code secondary}, {@code primaryPreferred}). Default: {@code primary}. + */ + private String readPreference = "primary"; + + /** + * Maximum number of pooled connections to MongoDB. Default: {@code 250}. + */ + private int maxConnections = 250; + + /** + * MongoDB Atlas SRV connection string. When set (non-null and non-blank), it + * overrides {@link #hosts} entirely — {@link MorphiumAutoConfiguration} configures + * the cluster from this URL instead of iterating {@link #hosts}. No default. + */ + private String atlasUrl; + + /** + * Name of the MongoDB replica set. Required for multi-document transactions + * (see {@link MorphiumTransactional}); a standalone MongoDB node does not support + * them. No default — if unset, Morphium connects without asserting a replica set + * name. + */ + private String replicaSetName; + + /** + * Number of connection attempts {@link MorphiumAutoConfiguration} makes before + * giving up when a transient connection error occurs (e.g. "no primary node + * found", "not connected yet"). Retries use a linear backoff of + * {@code attempt * 2000} milliseconds. Default: {@code 5}. Non-transient failures + * are never retried and propagate immediately. + */ + private int connectRetries = 5; + + /** + * Index management strategy applied at startup, one of {@code CREATE_ON_STARTUP} + * (create missing indexes eagerly), {@code WARN_ON_STARTUP} (log a warning instead + * of creating), {@code CREATE_ON_WRITE_NEW_COL} (defer index creation to the first + * write on a new collection), or {@code NO_CHECK} (skip index checking entirely). + * Default: {@code CREATE_ON_STARTUP}. + */ + private String indexCheck = "CREATE_ON_STARTUP"; + + /** + * Query result cache settings, bound under the {@code morphium.cache.*} prefix. + */ + private CacheProperties cache = new CacheProperties(); + + /** + * TLS/SSL connection settings, bound under the {@code morphium.ssl.*} prefix. + */ + private SslProperties ssl = new SslProperties(); + + /** + * Returns the configured MongoDB seed host list ({@code morphium.hosts}). + * + * @return comma-separated {@code host:port} entries; defaults to a single-element + * list containing {@code localhost:27017} + */ + public List getHosts() { return hosts; } + + /** + * Sets the MongoDB seed host list bound from {@code morphium.hosts}. Ignored by + * {@link MorphiumAutoConfiguration} if {@link #atlasUrl} is also set. + * + * @param hosts {@code host:port} entries to seed the MongoDB cluster connection + */ + public void setHosts(List hosts) { this.hosts = hosts; } + + /** + * Returns the configured MongoDB database name ({@code morphium.database}). + * + * @return the database name, or {@code null} if not yet configured + */ + public String getDatabase() { return database; } + + /** + * Sets the MongoDB database name bound from {@code morphium.database}. This value + * is required for {@link MorphiumAutoConfiguration} to build a working + * {@code MorphiumConfig} — Morphium connects successfully with a {@code null} + * database only in degenerate/test scenarios. + * + * @param database the database Morphium operates against + */ + public void setDatabase(String database) { this.database = database; } + + /** + * Returns the configured MongoDB username ({@code morphium.username}). + * + * @return the username, or {@code null} if authentication is not configured + */ + public String getUsername() { return username; } + + /** + * Sets the MongoDB username bound from {@code morphium.username}. Authentication + * is only applied by {@link MorphiumAutoConfiguration} once both this and + * {@link #password} are non-null. + * + * @param username the MongoDB username to authenticate with + */ + public void setUsername(String username) { this.username = username; } + + /** + * Returns the configured MongoDB password ({@code morphium.password}). + * + * @return the password, or {@code null} if authentication is not configured + */ + public String getPassword() { return password; } + + /** + * Sets the MongoDB password bound from {@code morphium.password}. See + * {@link #setUsername(String)} for when it takes effect. + * + * @param password the MongoDB password to authenticate with + */ + public void setPassword(String password) { this.password = password; } + + /** + * Returns the authentication database ({@code morphium.auth-database}). + * + * @return the database MongoDB authenticates {@link #username}/{@link #password} + * against; defaults to {@code admin} + */ + public String getAuthDatabase() { return authDatabase; } + + /** + * Sets the authentication database bound from {@code morphium.auth-database}. + * + * @param authDatabase the MongoDB {@code authSource} database + */ + public void setAuthDatabase(String authDatabase) { this.authDatabase = authDatabase; } + + /** + * Returns the configured driver implementation name ({@code morphium.driver-name}). + * + * @return {@code PooledDriver}, {@code InMemDriver}, or another Morphium driver + * name; defaults to {@code PooledDriver} + */ + public String getDriverName() { return driverName; } + + /** + * Sets the driver implementation name bound from {@code morphium.driver-name}. + * Use {@code InMemDriver} in tests to run against Morphium's in-memory MongoDB + * emulation without a real MongoDB instance. + * + * @param driverName the Morphium driver implementation to instantiate + */ + public void setDriverName(String driverName) { this.driverName = driverName; } + + /** + * Returns the configured read preference ({@code morphium.read-preference}). + * + * @return the MongoDB read preference; defaults to {@code primary} + */ + public String getReadPreference() { return readPreference; } + + /** + * Sets the MongoDB read preference bound from {@code morphium.read-preference}. + * + * @param readPreference one of MongoDB's read preference names, e.g. + * {@code primary}, {@code secondary}, {@code primaryPreferred} + */ + public void setReadPreference(String readPreference) { this.readPreference = readPreference; } + + /** + * Returns the configured connection pool size ({@code morphium.max-connections}). + * + * @return the maximum number of pooled MongoDB connections; defaults to {@code 250} + */ + public int getMaxConnections() { return maxConnections; } + + /** + * Sets the connection pool size bound from {@code morphium.max-connections}. + * + * @param maxConnections maximum number of pooled connections to MongoDB + */ + public void setMaxConnections(int maxConnections) { this.maxConnections = maxConnections; } + + /** + * Returns the configured MongoDB Atlas SRV URL ({@code morphium.atlas-url}). + * + * @return the Atlas connection string, or {@code null} if {@link #hosts} is used + * instead + */ + public String getAtlasUrl() { return atlasUrl; } + + /** + * Sets the MongoDB Atlas SRV URL bound from {@code morphium.atlas-url}. When set + * to a non-blank value, {@link MorphiumAutoConfiguration} uses it instead of + * {@link #hosts} to configure the cluster. + * + * @param atlasUrl the Atlas {@code mongodb+srv://...} connection string + */ + public void setAtlasUrl(String atlasUrl) { this.atlasUrl = atlasUrl; } + + /** + * Returns the configured replica set name ({@code morphium.replica-set-name}). + * + * @return the required replica set name, or {@code null} if not set + */ + public String getReplicaSetName() { return replicaSetName; } + + /** + * Sets the replica set name bound from {@code morphium.replica-set-name}. Required + * for {@code @}{@link MorphiumTransactional} to work — MongoDB rejects + * multi-document transactions on a standalone (non-replica-set) node. + * + * @param replicaSetName the MongoDB replica set name to require + */ + public void setReplicaSetName(String replicaSetName) { this.replicaSetName = replicaSetName; } + + /** + * Returns the configured connection retry count ({@code morphium.connect-retries}). + * + * @return the number of connection attempts before giving up; defaults to + * {@code 5} + */ + public int getConnectRetries() { return connectRetries; } + + /** + * Sets the connection retry count bound from {@code morphium.connect-retries}. + * {@link MorphiumAutoConfiguration} only retries transient connection failures + * (e.g. no primary elected yet); other exceptions propagate on the first attempt. + * + * @param connectRetries maximum number of connection attempts (at least 1 is + * always attempted regardless of this value) + */ + public void setConnectRetries(int connectRetries) { this.connectRetries = connectRetries; } + + /** + * Returns the configured index check mode ({@code morphium.index-check}). + * + * @return one of {@code CREATE_ON_STARTUP}, {@code WARN_ON_STARTUP}, + * {@code CREATE_ON_WRITE_NEW_COL}, {@code NO_CHECK}; defaults to + * {@code CREATE_ON_STARTUP} + */ + public String getIndexCheck() { return indexCheck; } + + /** + * Sets the index check mode bound from {@code morphium.index-check}. Any value + * other than the four documented modes is silently ignored by + * {@link MorphiumAutoConfiguration} (the underlying {@code MorphiumConfig} keeps + * its own default in that case). + * + * @param indexCheck the index management strategy name + */ + public void setIndexCheck(String indexCheck) { this.indexCheck = indexCheck; } + + /** + * Returns the query result cache settings ({@code morphium.cache.*}). + * + * @return the nested cache configuration + */ + public CacheProperties getCache() { return cache; } + + /** + * Replaces the query result cache settings bound from {@code morphium.cache.*}. + * + * @param cache the nested cache configuration to use + */ + public void setCache(CacheProperties cache) { this.cache = cache; } + + /** + * Returns the TLS/SSL settings ({@code morphium.ssl.*}). + * + * @return the nested SSL configuration + */ + public SslProperties getSsl() { return ssl; } + + /** + * Replaces the TLS/SSL settings bound from {@code morphium.ssl.*}. + * + * @param ssl the nested SSL configuration to use + */ + public void setSsl(SslProperties ssl) { this.ssl = ssl; } + + /** + * Query result cache settings, bound under {@code morphium.cache.*} and applied + * by {@link MorphiumAutoConfiguration} to + * {@code MorphiumConfig.cacheSettings()}. + */ + public static class CacheProperties { + + /** + * Time-to-live, in milliseconds, for cached query results before Morphium + * considers a cache entry invalid. Default: {@code 5000} (5 seconds). + */ + private int globalValidTime = 5000; + + /** + * Whether Morphium's read cache is enabled at all. When {@code false}, every + * query bypasses the cache regardless of any per-query or per-entity cache + * annotation. Default: {@code true}. + */ + private boolean readCacheEnabled = true; + + /** + * Returns the cache TTL ({@code morphium.cache.global-valid-time}). + * + * @return the cache validity duration in milliseconds; defaults to + * {@code 5000} + */ + public int getGlobalValidTime() { return globalValidTime; } + + /** + * Sets the cache TTL bound from {@code morphium.cache.global-valid-time}. + * + * @param globalValidTime cache validity duration in milliseconds + */ + public void setGlobalValidTime(int globalValidTime) { this.globalValidTime = globalValidTime; } + + /** + * Returns whether the read cache is enabled + * ({@code morphium.cache.read-cache-enabled}). + * + * @return {@code true} if query results may be cached; defaults to + * {@code true} + */ + public boolean isReadCacheEnabled() { return readCacheEnabled; } + + /** + * Sets whether the read cache is enabled, bound from + * {@code morphium.cache.read-cache-enabled}. + * + * @param readCacheEnabled {@code false} to disable query result caching + * entirely + */ + public void setReadCacheEnabled(boolean readCacheEnabled) { this.readCacheEnabled = readCacheEnabled; } + } + + /** + * TLS/SSL connection settings, bound under {@code morphium.ssl.*} and applied by + * {@link MorphiumAutoConfiguration} when {@link #enabled} is {@code true}. Only + * keystore-based client configuration is exposed here; truststore configuration + * is not covered by this module. + */ + public static class SslProperties { + + /** + * Whether Morphium connects to MongoDB over TLS. Default: {@code false}. When + * {@code true}, {@link MorphiumAutoConfiguration} builds an {@code SSLContext} + * (using {@link #keystorePath}/{@link #keystorePassword} if set) and enables + * it on the driver. + */ + private boolean enabled = false; + + /** + * Filesystem path to a keystore (JKS or PKCS12) holding the client + * certificate/private key for TLS. No default. Only read when {@link #enabled} + * is {@code true}; if {@code null} while {@link #enabled} is {@code true}, + * TLS is enabled without a client keystore. + */ + private String keystorePath; + + /** + * Password protecting {@link #keystorePath}. No default. + */ + private String keystorePassword; + + /** + * Returns whether TLS is enabled ({@code morphium.ssl.enabled}). + * + * @return {@code true} if Morphium connects over TLS; defaults to + * {@code false} + */ + public boolean isEnabled() { return enabled; } + + /** + * Sets whether TLS is enabled, bound from {@code morphium.ssl.enabled}. + * + * @param enabled {@code true} to connect to MongoDB over TLS + */ + public void setEnabled(boolean enabled) { this.enabled = enabled; } + + /** + * Returns the configured keystore path ({@code morphium.ssl.keystore-path}). + * + * @return the keystore file path, or {@code null} if not configured + */ + public String getKeystorePath() { return keystorePath; } + + /** + * Sets the keystore path bound from {@code morphium.ssl.keystore-path}. + * + * @param keystorePath filesystem path to a JKS or PKCS12 keystore + */ + public void setKeystorePath(String keystorePath) { this.keystorePath = keystorePath; } + + /** + * Returns the configured keystore password + * ({@code morphium.ssl.keystore-password}). + * + * @return the password protecting the keystore, or {@code null} if not + * configured + */ + public String getKeystorePassword() { return keystorePassword; } + + /** + * Sets the keystore password bound from {@code morphium.ssl.keystore-password}. + * + * @param keystorePassword password protecting {@link #keystorePath} + */ + public void setKeystorePassword(String keystorePassword) { this.keystorePassword = keystorePassword; } + } +} diff --git a/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumRepositoryFactoryBean.java b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumRepositoryFactoryBean.java new file mode 100644 index 000000000..a42a7c24f --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumRepositoryFactoryBean.java @@ -0,0 +1,178 @@ +package de.caluga.morphium.spring.autoconfigure; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.annotations.Id; +import de.caluga.morphium.data.RepositoryMetadata; +import jakarta.data.repository.CrudRepository; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.annotation.Autowired; + +import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Proxy; +import java.lang.reflect.Type; + +/** + * Spring {@link FactoryBean} that creates a {@link Proxy JDK dynamic proxy} + * implementing a Morphium Jakarta Data repository interface. {@link + * MorphiumRepositoryRegistrar} registers exactly one bean definition of this type per + * {@code @Repository} interface discovered under {@link EnableMorphiumRepositories} — + * application code never instantiates this class directly. + * + *

    At bean-creation time (see {@link #getObject()}), it resolves the repository + * interface's entity and ID type arguments, finds the entity's {@code @Id} field, + * builds a {@code RepositoryMetadata}, and creates a proxy backed by a + * {@link MorphiumRepositoryInvocationHandler}. This is the mechanism-level detail + * behind {@link EnableMorphiumRepositories}'s "JDK dynamic proxy at runtime" — no + * class is generated or compiled; {@code java.lang.reflect.Proxy} synthesizes the + * implementing class in the running JVM, once per repository interface, the first + * time the bean is requested (the bean is a singleton, so this happens at most once + * per application context). + * + * @param the repository interface type this factory bean produces + */ +public class MorphiumRepositoryFactoryBean implements FactoryBean { + + private final Class repositoryInterface; + + @Autowired + private Morphium morphium; + + /** + * Creates a factory bean for the given repository interface. Called only by + * {@link MorphiumRepositoryRegistrar} while building the bean definition; the + * {@link Morphium} dependency is injected afterwards by Spring + * ({@code @Autowired}), not passed here. + * + * @param repositoryInterface the {@code @Repository} interface this factory bean + * will produce a proxy implementation for + */ + public MorphiumRepositoryFactoryBean(Class repositoryInterface) { + this.repositoryInterface = repositoryInterface; + } + + /** + * Creates the JDK dynamic proxy implementing {@code repositoryInterface}. Resolves + * the entity and ID type arguments from the interface's {@code CrudRepository} supertype (see {@link #resolveTypeArguments(Class)}), locates the entity's + * {@code @Id} field name (see {@link #findIdFieldName(Class)}), and wires both + * into a new {@link MorphiumRepositoryInvocationHandler} that dispatches every + * proxied method call to the shared {@code morphium-jakarta-data} runtime. + * + * @return a new proxy instance implementing the repository interface; a fresh + * instance is returned on every call, but {@link #isSingleton()} tells + * Spring to only call this once and cache the result + * @throws IllegalArgumentException if the entity/ID type arguments cannot be + * resolved from the repository interface hierarchy, or if the entity class + * has no {@code @Id}-annotated field and no fallback {@code id}/{@code + * morphiumId} field either + */ + @Override + @SuppressWarnings("unchecked") + public T getObject() { + var typeArgs = resolveTypeArguments(repositoryInterface); + Class entityClass = typeArgs[0]; + Class idClass = typeArgs[1]; + String idFieldName = findIdFieldName(entityClass); + + var metadata = new RepositoryMetadata(entityClass, idClass, idFieldName); + var handler = new MorphiumRepositoryInvocationHandler(morphium, metadata, repositoryInterface); + + return (T) Proxy.newProxyInstance( + repositoryInterface.getClassLoader(), + new Class[]{ repositoryInterface }, + handler); + } + + /** + * Reports the repository interface itself as this factory bean's product type, + * so Spring's type-based autowiring (e.g. {@code @Autowired ProductRepository}) + * resolves to the proxy this factory bean produces. + * + * @return the {@code @Repository} interface class passed to the constructor + */ + @Override + public Class getObjectType() { + return repositoryInterface; + } + + /** + * Declares that {@link #getObject()} is called at most once and its result cached + * by Spring — the same proxy instance is returned to every injection point. + * + * @return always {@code true} + */ + @Override + public boolean isSingleton() { + return true; + } + + /** + * Walks the interface hierarchy to find CrudRepository<T, K> type arguments. + * + * @param repoInterface the repository interface (or a super-interface reached + * through recursion) to inspect + * @return a two-element array {@code { entityClass, idClass }} resolved from the + * first {@code CrudRepository}-parameterized supertype found + * @throws IllegalArgumentException if no generic {@code CrudRepository} + * supertype with resolvable type arguments exists anywhere in the + * interface hierarchy + */ + static Class[] resolveTypeArguments(Class repoInterface) { + for (Type iface : repoInterface.getGenericInterfaces()) { + if (iface instanceof ParameterizedType pt) { + Type raw = pt.getRawType(); + if (raw instanceof Class rawClass && CrudRepository.class.isAssignableFrom(rawClass)) { + Type[] args = pt.getActualTypeArguments(); + if (args.length >= 2 && args[0] instanceof Class entity && args[1] instanceof Class id) { + return new Class[]{ entity, id }; + } + } + } + } + // Recurse into super-interfaces + for (Class superIface : repoInterface.getInterfaces()) { + try { + return resolveTypeArguments(superIface); + } catch (IllegalArgumentException ignored) { + } + } + throw new IllegalArgumentException( + "Cannot resolve entity/id types from " + repoInterface.getName()); + } + + /** + * Finds the field annotated with {@code @Id} in the entity class hierarchy. + * + * @param entityClass the entity class to search, including its superclasses + * @return the name of the {@code @Id}-annotated field, or — if none is found — the + * name of a field literally called {@code id} or {@code morphiumId} as a + * fallback + * @throws IllegalArgumentException if neither an {@code @Id}-annotated field nor a + * fallback {@code id}/{@code morphiumId} field exists on {@code entityClass} + */ + private static String findIdFieldName(Class entityClass) { + Class cls = entityClass; + while (cls != null && cls != Object.class) { + for (Field f : cls.getDeclaredFields()) { + if (f.isAnnotationPresent(Id.class)) { + return f.getName(); + } + } + cls = cls.getSuperclass(); + } + // Fallback: look for a field named "id" or "morphiumId" + try { + entityClass.getDeclaredField("id"); + return "id"; + } catch (NoSuchFieldException ignored) { + } + try { + entityClass.getDeclaredField("morphiumId"); + return "morphiumId"; + } catch (NoSuchFieldException ignored) { + } + throw new IllegalArgumentException( + "No @Id field found in " + entityClass.getName()); + } +} diff --git a/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumRepositoryInvocationHandler.java b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumRepositoryInvocationHandler.java new file mode 100644 index 000000000..f3464988a --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumRepositoryInvocationHandler.java @@ -0,0 +1,394 @@ +package de.caluga.morphium.spring.autoconfigure; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.data.*; +import jakarta.data.Order; +import jakarta.data.Sort; +import jakarta.data.Limit; +import jakarta.data.page.CursoredPage; +import jakarta.data.page.Page; +import jakarta.data.page.PageRequest; +import jakarta.data.repository.By; +import jakarta.data.repository.Delete; +import jakarta.data.repository.Find; +import jakarta.data.repository.OrderBy; +import jakarta.data.repository.Param; +import jakarta.data.repository.Query; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +/** + * JDK dynamic proxy handler for Morphium repository interfaces. + * Dispatches method calls to CRUD operations on {@link AbstractMorphiumRepository} + * or to the query bridges ({@link QueryMethodBridge}, {@link JdqlMethodBridge}, + * {@link FindMethodBridge}) from the shared morphium-jakarta-data module. + */ +class MorphiumRepositoryInvocationHandler implements InvocationHandler { + + private final SpringMorphiumRepository delegate; + private final Class repositoryInterface; + private final ConcurrentHashMap handlers = new ConcurrentHashMap<>(); + + MorphiumRepositoryInvocationHandler(Morphium morphium, RepositoryMetadata metadata, + Class repositoryInterface) { + this.repositoryInterface = repositoryInterface; + this.delegate = new SpringMorphiumRepository(metadata); + this.delegate.setMorphium(morphium); + } + + @Override + public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { + // Object methods + if (method.getDeclaringClass() == Object.class) { + return switch (method.getName()) { + case "toString" -> repositoryInterface.getSimpleName() + "@MorphiumProxy"; + case "hashCode" -> System.identityHashCode(proxy); + case "equals" -> proxy == args[0]; + default -> method.invoke(this, args); + }; + } + + // A default method carries its own implementation, so it must run as written instead + // of being analysed as a query. Handled here rather than in analyzeMethod() because + // InvocationHandler.invokeDefault needs the proxy instance, which the MethodHandler + // functional interface (args only) cannot carry - and handled BEFORE the derived-query + // check further down, since a default method is free to be named findBy*/countBy*. + // Without this, any default method ended in the "Unsupported repository method" + // UnsupportedOperationException at the bottom of analyzeMethod(). + if (method.isDefault()) { + return InvocationHandler.invokeDefault(proxy, method, args); + } + + return handlers.computeIfAbsent(method, this::analyzeMethod).handle(args); + } + + private MethodHandler analyzeMethod(Method method) { + String name = method.getName(); + Class returnType = method.getReturnType(); + + // --- CrudRepository standard methods --- + if (name.equals("findById") && method.getParameterCount() == 1) { + return args -> delegate.doFindById(args[0]); + } + if (name.equals("findAll") && method.getParameterCount() == 0) { + return args -> delegate.doFindAll(); + } + if (name.equals("findAll") && method.getParameterCount() == 2) { + return args -> delegate.doFindAllPaged((PageRequest) args[0], (Order) args[1]); + } + if (name.equals("save") && method.getParameterCount() == 1) { + return args -> delegate.doSave(args[0]); + } + if (name.equals("saveAll") && method.getParameterCount() == 1) { + return args -> delegate.doSaveAll((List) args[0]); + } + if (name.equals("insert") && method.getParameterCount() == 1) { + return args -> delegate.doInsert(args[0]); + } + if (name.equals("insertAll") && method.getParameterCount() == 1) { + return args -> delegate.doInsertAll((List) args[0]); + } + if (name.equals("update") && method.getParameterCount() == 1) { + return args -> delegate.doUpdate(args[0]); + } + if (name.equals("updateAll") && method.getParameterCount() == 1) { + return args -> delegate.doUpdateAll((List) args[0]); + } + if (name.equals("delete") && method.getParameterCount() == 1) { + return args -> { delegate.doDelete(args[0]); return null; }; + } + if (name.equals("deleteById") && method.getParameterCount() == 1) { + return args -> { delegate.doDeleteById(args[0]); return null; }; + } + if (name.equals("deleteAll") && method.getParameterCount() == 1) { + return args -> { delegate.doDeleteAll((List) args[0]); return null; }; + } + if (name.equals("deleteAll") && method.getParameterCount() == 0) { + return args -> { delegate.doDeleteAllNoArg(); return null; }; + } + + // --- MorphiumRepository extensions --- + if (name.equals("distinct") && method.getParameterCount() == 1) { + return args -> delegate.doDistinct((String) args[0]); + } + if (name.equals("morphium") && method.getParameterCount() == 0) { + return args -> delegate.doMorphium(); + } + if (name.equals("query") && method.getParameterCount() == 0) { + return args -> delegate.doQuery(); + } + + // --- @Query (JDQL) --- + Query queryAnno = method.getAnnotation(Query.class); + if (queryAnno != null) { + return buildJdqlHandler(method, queryAnno); + } + + // --- @Find --- + Find findAnno = method.getAnnotation(Find.class); + if (findAnno != null) { + return buildFindHandler(method); + } + + // --- @Delete --- + Delete deleteAnno = method.getAnnotation(Delete.class); + if (deleteAnno != null) { + return buildDeleteHandler(method); + } + + // --- Derived query (findBy*, countBy*, existsBy*, deleteBy*) --- + if (name.startsWith("findBy") || name.startsWith("countBy") + || name.startsWith("existsBy") || name.startsWith("deleteBy")) { + return buildDerivedQueryHandler(method); + } + + throw new UnsupportedOperationException( + "Unsupported repository method: " + repositoryInterface.getSimpleName() + "." + name); + } + + private MethodHandler buildDerivedQueryHandler(Method method) { + boolean returnsAsync = CompletionStage.class.isAssignableFrom(method.getReturnType()); + boolean returnsSingle = !List.class.isAssignableFrom(method.getReturnType()) + && !Stream.class.isAssignableFrom(method.getReturnType()) + && !Page.class.isAssignableFrom(method.getReturnType()) + && !Iterable.class.isAssignableFrom(method.getReturnType()) + && !method.getReturnType().equals(long.class) + && !method.getReturnType().equals(Long.class) + && !method.getReturnType().equals(boolean.class) + && !method.getReturnType().equals(Boolean.class) + && !Optional.class.isAssignableFrom(method.getReturnType()) + && !returnsAsync; + boolean returnsOptional = Optional.class.isAssignableFrom(method.getReturnType()); + boolean returnsBoolean = method.getReturnType() == boolean.class + || method.getReturnType() == Boolean.class; + boolean returnsStream = Stream.class.isAssignableFrom(method.getReturnType()); + + String orderBySpec = getOrderBySpec(method); + + // Regression fix: this handler never looked up dynamic Sort/Order/PageRequest/Limit + // parameters and always dispatched to the simple executeQuery overload -- a Page + // method got a plain List back (ClassCastException at the proxy boundary) and a + // Sort argument was silently dropped (wrong order, no error). Determine the four + // indices the same way buildJdqlHandler/buildFindHandler already do and always call + // the overload that takes them; it short-circuits back to the simple overload itself + // when all four indices are -1, so this is safe for the common case too. + int sortIdx = findParamIndex(method, Sort.class); + int orderIdx = findParamIndex(method, Order.class); + int pageRequestIdx = findParamIndex(method, PageRequest.class); + int limitIdx = findParamIndex(method, Limit.class); + + // Strip the "Async" suffix for parsing (e.g. "findByStatusAsync" -> "findByStatus"), + // matching quarkus-morphium's MorphiumDataProcessor convention for derived-query + // methods with a CompletionStage return type. + String methodName = method.getName(); + String parseableName = returnsAsync && methodName.endsWith("Async") + ? methodName.substring(0, methodName.length() - 5) : methodName; + + if (returnsAsync) { + return args -> QueryMethodBridge.executeQueryAsync( + delegate, parseableName, args != null ? args : new Object[0], + returnsSingle, returnsOptional, returnsBoolean, returnsStream, orderBySpec, + sortIdx, orderIdx, pageRequestIdx, limitIdx); + } + return args -> QueryMethodBridge.executeQuery( + delegate, parseableName, args != null ? args : new Object[0], + returnsSingle, returnsOptional, returnsBoolean, returnsStream, orderBySpec, + sortIdx, orderIdx, pageRequestIdx, limitIdx); + } + + private MethodHandler buildJdqlHandler(Method method, Query queryAnno) { + String jdql = queryAnno.value(); + String paramMapSpec = buildParamMapSpec(method); + int sortIdx = findParamIndex(method, Sort.class); + int orderIdx = findParamIndex(method, Order.class); + int pageRequestIdx = findParamIndex(method, PageRequest.class); + int limitIdx = findParamIndex(method, Limit.class); + + boolean returnsAsync = CompletionStage.class.isAssignableFrom(method.getReturnType()); + boolean returnsSingle = isSingleReturn(method); + boolean returnsCount = method.getReturnType() == long.class || method.getReturnType() == Long.class; + boolean returnsBoolean = method.getReturnType() == boolean.class || method.getReturnType() == Boolean.class; + boolean returnsOptional = Optional.class.isAssignableFrom(method.getReturnType()); + boolean returnsCursoredPage = CursoredPage.class.isAssignableFrom(method.getReturnType()); + boolean returnsStream = Stream.class.isAssignableFrom(method.getReturnType()); + String orderBySpec = getOrderBySpec(method); + + // Regression fix: neither this method nor isSingleReturn(Method) excluded + // CompletionStage, so a `CompletionStage>` method was analyzed as a + // single-entity query, ran synchronously on the caller's thread, and handed the + // entity itself to the proxy where a CompletionStage was expected -- + // ClassCastException. Dispatch to the async bridge with the same parameters as + // the sync call, matching quarkus-morphium's convention (used already by + // buildDerivedQueryHandler) that a CompletionStage-returning query method + // resolves to a plain (non-single, non-Optional) result. + if (returnsAsync) { + return args -> JdqlMethodBridge.executeJdqlAsync( + delegate, jdql, paramMapSpec, + sortIdx, orderIdx, pageRequestIdx, limitIdx, + args != null ? args : new Object[0], + returnsSingle, returnsCount, returnsBoolean, returnsOptional, + returnsCursoredPage, orderBySpec, returnsStream, null); + } + + return args -> JdqlMethodBridge.executeJdql( + delegate, jdql, paramMapSpec, + sortIdx, orderIdx, pageRequestIdx, limitIdx, + args != null ? args : new Object[0], + returnsSingle, returnsCount, returnsBoolean, returnsOptional, + returnsCursoredPage, orderBySpec, returnsStream, null); + } + + private MethodHandler buildFindHandler(Method method) { + String conditionsSpec = buildConditionsSpec(method); + String orderBySpec = getOrderBySpec(method); + int sortIdx = findParamIndex(method, Sort.class); + int orderIdx = findParamIndex(method, Order.class); + int pageRequestIdx = findParamIndex(method, PageRequest.class); + int limitIdx = findParamIndex(method, Limit.class); + + boolean returnsAsync = CompletionStage.class.isAssignableFrom(method.getReturnType()); + boolean returnsSingle = isSingleReturn(method); + boolean returnsOptional = Optional.class.isAssignableFrom(method.getReturnType()); + boolean returnsCursoredPage = CursoredPage.class.isAssignableFrom(method.getReturnType()); + boolean returnsStream = Stream.class.isAssignableFrom(method.getReturnType()); + + // Regression fix: same CompletionStage gap as buildJdqlHandler above -- a + // `CompletionStage>` @Find method ran synchronously and returned the + // entity/list directly instead of a CompletionStage, causing a + // ClassCastException at the proxy boundary. + if (returnsAsync) { + return args -> FindMethodBridge.executeFindAsync( + delegate, conditionsSpec, orderBySpec, + sortIdx, orderIdx, pageRequestIdx, limitIdx, + args != null ? args : new Object[0], + returnsSingle, returnsOptional, returnsCursoredPage, returnsStream); + } + + return args -> FindMethodBridge.executeFind( + delegate, conditionsSpec, orderBySpec, + sortIdx, orderIdx, pageRequestIdx, limitIdx, + args != null ? args : new Object[0], + returnsSingle, returnsOptional, returnsCursoredPage, returnsStream); + } + + private MethodHandler buildDeleteHandler(Method method) { + String conditionsSpec = buildConditionsSpec(method); + Class returnType = method.getReturnType(); + + // Regression fix: Jakarta Data 1.0 permits void, int, and long return types for + // @Delete methods -- the numeric variants must return the number of deleted + // entities. This used to always call the void bridge and return null, which blew + // up as a NullPointerException when the proxy tried to unbox null into a + // primitive long/int return value. + if (returnType == long.class || returnType == Long.class) { + return args -> FindMethodBridge.executeAnnotatedDeleteCounted( + delegate, conditionsSpec, args != null ? args : new Object[0]); + } + if (returnType == int.class || returnType == Integer.class) { + return args -> (int) FindMethodBridge.executeAnnotatedDeleteCounted( + delegate, conditionsSpec, args != null ? args : new Object[0]); + } + return args -> { + FindMethodBridge.executeAnnotatedDelete( + delegate, conditionsSpec, args != null ? args : new Object[0]); + return null; + }; + } + + // --- Helpers --- + + private String buildParamMapSpec(Method method) { + StringBuilder sb = new StringBuilder(); + Parameter[] params = method.getParameters(); + for (int i = 0; i < params.length; i++) { + Param paramAnno = params[i].getAnnotation(Param.class); + if (paramAnno != null) { + if (sb.length() > 0) sb.append(","); + sb.append(paramAnno.value()).append(":").append(i); + } else if (!isSpecialParam(params[i].getType())) { + // Use parameter name (requires -parameters compiler flag) + if (sb.length() > 0) sb.append(","); + sb.append(params[i].getName()).append(":").append(i); + } + } + return sb.toString(); + } + + private String buildConditionsSpec(Method method) { + StringBuilder sb = new StringBuilder(); + Parameter[] params = method.getParameters(); + for (int i = 0; i < params.length; i++) { + if (isSpecialParam(params[i].getType())) continue; + By byAnno = params[i].getAnnotation(By.class); + String fieldName = byAnno != null ? byAnno.value() : params[i].getName(); + if (sb.length() > 0) sb.append(","); + sb.append(fieldName).append(":").append(i); + } + return sb.toString(); + } + + private static boolean isSpecialParam(Class type) { + return Sort.class.isAssignableFrom(type) + || Order.class.isAssignableFrom(type) + || PageRequest.class.isAssignableFrom(type) + || Limit.class.isAssignableFrom(type); + } + + private static int findParamIndex(Method method, Class paramType) { + Parameter[] params = method.getParameters(); + for (int i = 0; i < params.length; i++) { + if (paramType.isAssignableFrom(params[i].getType())) { + return i; + } + } + return -1; + } + + private boolean isSingleReturn(Method method) { + Class rt = method.getReturnType(); + return !List.class.isAssignableFrom(rt) + && !Stream.class.isAssignableFrom(rt) + && !Page.class.isAssignableFrom(rt) + && !CursoredPage.class.isAssignableFrom(rt) + && !Iterable.class.isAssignableFrom(rt) + && !Optional.class.isAssignableFrom(rt) + && !CompletionStage.class.isAssignableFrom(rt) + && rt != long.class && rt != Long.class + && rt != boolean.class && rt != Boolean.class + && rt != void.class && rt != Void.class; + } + + private static String getOrderBySpec(Method method) { + OrderBy orderBy = method.getAnnotation(OrderBy.class); + if (orderBy == null) return ""; + return orderBy.value(); + } + + @FunctionalInterface + private interface MethodHandler { + Object handle(Object[] args) throws Exception; + } + + /** + * Concrete (non-abstract) subclass of AbstractMorphiumRepository for Spring proxy use. + * The setMorphium() method is package-visible through the parent. + */ + private static class SpringMorphiumRepository extends AbstractMorphiumRepository { + SpringMorphiumRepository(RepositoryMetadata metadata) { + super(metadata); + } + + @Override + public void setMorphium(Morphium morphium) { + super.setMorphium(morphium); + } + } +} diff --git a/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumRepositoryRegistrar.java b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumRepositoryRegistrar.java new file mode 100644 index 000000000..93e3261cd --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumRepositoryRegistrar.java @@ -0,0 +1,129 @@ +package de.caluga.morphium.spring.autoconfigure; + +import de.caluga.morphium.data.MorphiumRepository; +import jakarta.data.repository.CrudRepository; +import jakarta.data.repository.Repository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; +import org.springframework.context.annotation.ImportBeanDefinitionRegistrar; +import org.springframework.core.annotation.AnnotationAttributes; +import org.springframework.core.type.AnnotationMetadata; +import org.springframework.beans.factory.annotation.AnnotatedBeanDefinition; +import org.springframework.core.type.filter.AnnotationTypeFilter; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +/** + * {@link ImportBeanDefinitionRegistrar} that performs the actual classpath scan + * behind {@link EnableMorphiumRepositories}: it looks for interfaces annotated with + * {@code jakarta.data.repository.Repository} that extend {@link CrudRepository} or + * {@link MorphiumRepository}, and registers one {@link MorphiumRepositoryFactoryBean} + * bean definition per interface found. Never referenced directly by application + * code — Spring instantiates and invokes it automatically because + * {@code @EnableMorphiumRepositories} carries {@code @Import(MorphiumRepositoryRegistrar.class)}. + * + *

    This is where this module's proxy mechanism differs architecturally from the + * {@code quarkus-morphium} extension: this class runs at Spring context-startup time, + * in the running JVM, and only ever registers a {@link MorphiumRepositoryFactoryBean} + * — a {@code FactoryBean} that later produces a + * {@link java.lang.reflect.Proxy JDK dynamic proxy}. No bytecode is generated or + * written to disk. Quarkus's equivalent mechanism runs as a build-time processor and + * emits a real, compiled implementation class via Gizmo before the application ever + * starts. See {@link EnableMorphiumRepositories} for the full comparison. + */ +public class MorphiumRepositoryRegistrar implements ImportBeanDefinitionRegistrar { + + private static final Logger log = LoggerFactory.getLogger(MorphiumRepositoryRegistrar.class); + + /** + * Scans the base packages derived from {@code @EnableMorphiumRepositories} (see + * {@link #getBasePackages(AnnotationMetadata)}) for interfaces annotated with + * {@code @Repository} that also extend {@link CrudRepository} or + * {@link MorphiumRepository}, and registers a {@link MorphiumRepositoryFactoryBean} + * bean definition — constructed with the repository interface as its sole + * constructor argument and wired by type — for each match. The registered bean + * name is the uncapitalized simple interface name (e.g. {@code ProductRepository} + * becomes {@code productRepository}). Candidates that fail to load are logged as + * a warning and skipped; interfaces that are annotated {@code @Repository} but do + * not extend either supported base interface are silently skipped. + * + * @param importingClassMetadata metadata of the class carrying + * {@code @EnableMorphiumRepositories}, used to read + * its {@code value()}/{@code basePackages()} + * attributes + * @param registry the bean definition registry to register discovered repository + * factory beans into + */ + @Override + public void registerBeanDefinitions(AnnotationMetadata importingClassMetadata, + BeanDefinitionRegistry registry) { + Set basePackages = getBasePackages(importingClassMetadata); + if (basePackages.isEmpty()) { + return; + } + + var scanner = new ClassPathScanningCandidateComponentProvider(false) { + @Override + protected boolean isCandidateComponent(AnnotatedBeanDefinition beanDefinition) { + // Allow interfaces (default implementation rejects them) + return beanDefinition.getMetadata().isInterface() + && beanDefinition.getMetadata().isIndependent(); + } + }; + scanner.addIncludeFilter(new AnnotationTypeFilter(Repository.class)); + + for (String basePackage : basePackages) { + for (var candidate : scanner.findCandidateComponents(basePackage)) { + String className = candidate.getBeanClassName(); + if (className == null) continue; + + try { + Class iface = ClassUtils.forName(className, getClass().getClassLoader()); + if (!iface.isInterface()) continue; + if (!CrudRepository.class.isAssignableFrom(iface) + && !MorphiumRepository.class.isAssignableFrom(iface)) { + continue; + } + + String beanName = StringUtils.uncapitalize(iface.getSimpleName()); + + var bd = BeanDefinitionBuilder.genericBeanDefinition(MorphiumRepositoryFactoryBean.class) + .addConstructorArgValue(iface) + .setAutowireMode(AbstractBeanDefinition.AUTOWIRE_BY_TYPE) + .getBeanDefinition(); + + registry.registerBeanDefinition(beanName, bd); + log.debug("Registered Morphium repository bean '{}' for {}", beanName, className); + } catch (ClassNotFoundException e) { + log.warn("Could not load repository candidate class: {}", className); + } + } + } + } + + private Set getBasePackages(AnnotationMetadata metadata) { + Set packages = new HashSet<>(); + + var attrs = AnnotationAttributes.fromMap( + metadata.getAnnotationAttributes(EnableMorphiumRepositories.class.getName())); + + if (attrs != null) { + packages.addAll(Arrays.asList(attrs.getStringArray("value"))); + packages.addAll(Arrays.asList(attrs.getStringArray("basePackages"))); + } + + if (packages.isEmpty()) { + packages.add(ClassUtils.getPackageName(metadata.getClassName())); + } + + return packages; + } +} diff --git a/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumTransactionAspect.java b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumTransactionAspect.java new file mode 100644 index 000000000..555032dfa --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumTransactionAspect.java @@ -0,0 +1,124 @@ +package de.caluga.morphium.spring.autoconfigure; + +import de.caluga.morphium.Morphium; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; + +/** + * AspectJ aspect that wraps every method (or every method of every class) annotated + * with {@code @}{@link MorphiumTransactional} in a Morphium transaction: + * {@code startTransaction()} before the method runs, {@code commitTransaction()} on + * normal return, {@code abortTransaction()} if the method throws. + * + *

    Registered as {@code @AutoConfiguration} (not a plain {@code @Component} + * picked up by component scan — see the "why" note below), so it only becomes an + * active Spring bean — and only then does its {@code @Around} advice apply — when + * both hold: + *

      + *
    • {@code org.aspectj.lang.annotation.Aspect} is on the classpath + * ({@code @ConditionalOnClass(name = "org.aspectj.lang.annotation.Aspect")}) — + * i.e. {@code spring-boot-starter-aop} (an optional dependency of this module) + * is present;
    • + *
    • a {@link Morphium} bean already exists in the context + * ({@code @ConditionalOnBean}).
    • + *
    + *

    Why {@code @AutoConfiguration} and not {@code @Component}: this class + * lives in {@code de.caluga.morphium.spring.autoconfigure}, a package that belongs to + * this library, not to any application using it. Spring Boot's component scan only + * looks at the application's own base package (and its sub-packages) unless told + * otherwise, so a plain {@code @Component} here is picked up only by coincidence — + * for any real application depending on this starter as an external jar, it is + * simply never scanned, silently leaving {@code @MorphiumTransactional} methods + * running without a transaction. Registering this class in + * {@code META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports} + * (alongside {@link MorphiumAutoConfiguration} and + * {@link MorphiumHealthAutoConfiguration}) makes Spring Boot's auto-configuration + * import mechanism instantiate it regardless of the application's package structure.

    + * + *

    Requires a MongoDB replica set or Atlas cluster + * ({@code morphium.replica-set-name}) — a standalone MongoDB node rejects + * multi-document transactions. + * + *

    {@code
    + * @Service
    + * public class OrderService {
    + *     @Autowired Morphium morphium;
    + *
    + *     @MorphiumTransactional
    + *     public void placeOrder(Order order, Payment payment) {
    + *         morphium.store(order);
    + *         morphium.store(payment);
    + *         // committed automatically on return, rolled back automatically on exception
    + *     }
    + * }
    + * }
    + */ +@Aspect +@AutoConfiguration(after = MorphiumAutoConfiguration.class) +@ConditionalOnClass(name = "org.aspectj.lang.annotation.Aspect") +@ConditionalOnBean(Morphium.class) +public class MorphiumTransactionAspect { + + private final Morphium morphium; + + /** + * Creates the aspect bound to the application's single {@link Morphium} instance. + * Instantiated by Spring, not application code — see the class-level + * {@code @Conditional} documentation for when this happens. + * + * @param morphium the {@link Morphium} bean every advised method's transaction is + * started, committed, or aborted on + */ + public MorphiumTransactionAspect(Morphium morphium) { + this.morphium = morphium; + } + + /** + * Advice applied around any method annotated with {@code @MorphiumTransactional}, + * or any method of a class annotated with it. Calls + * {@code morphium.startTransaction()} before {@code pjp.proceed()}; on normal + * completion, calls {@code commitTransaction()} and returns the method's result + * unchanged; if {@code pjp.proceed()} throws anything, calls + * {@code abortTransaction()} and rethrows the original exception unchanged. + * + *

    REQUIRED propagation (same semantics as quarkus-morphium's + * {@code MorphiumTransactionalInterceptor}): when a transaction is already active on + * this thread, the invocation simply joins it - no second {@code startTransaction()}, + * and neither commit nor abort here, because the outermost advised call owns the + * transaction's outcome. This matters because all drivers reject a second + * {@code startTransaction()} with an {@code IllegalArgumentException}; without joining, + * one {@code @MorphiumTransactional} service calling another would abort the OUTER + * transaction and lose all of its work. No explicit nesting counter is needed: + * Morphium already tracks the active transaction per thread. + * + *

    No rollback rules. Unlike Spring's {@code @Transactional}, which by default + * rolls back on unchecked exceptions only, this aspect aborts on any + * {@code Throwable} - including checked exceptions and {@code Error}s. + * + * @param pjp the join point representing the intercepted method invocation + * @return whatever the advised method returned + * @throws Throwable whatever the advised method threw, after the transaction has + * been aborted + */ + @Around("@annotation(de.caluga.morphium.spring.autoconfigure.MorphiumTransactional) || " + + "@within(de.caluga.morphium.spring.autoconfigure.MorphiumTransactional)") + public Object aroundTransactional(ProceedingJoinPoint pjp) throws Throwable { + // REQUIRED propagation: if a transaction is already active, just participate. + if (morphium.getTransaction() != null) { + return pjp.proceed(); + } + morphium.startTransaction(); + try { + Object result = pjp.proceed(); + morphium.commitTransaction(); + return result; + } catch (Throwable t) { + morphium.abortTransaction(); + throw t; + } + } +} diff --git a/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumTransactional.java b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumTransactional.java new file mode 100644 index 000000000..29562cc78 --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/java/de/caluga/morphium/spring/autoconfigure/MorphiumTransactional.java @@ -0,0 +1,31 @@ +package de.caluga.morphium.spring.autoconfigure; + +import java.lang.annotation.*; + +/** + * Marks a method, or every method of a class, to run inside a Morphium transaction. + * {@link MorphiumTransactionAspect} intercepts every call to an annotated element, + * calling {@code Morphium.startTransaction()} beforehand and, depending on outcome, + * either {@code commitTransaction()} (normal return) or {@code abortTransaction()} + * (any thrown exception) afterwards — the caller does not manage the transaction + * manually. + * + *

    Requires a MongoDB replica set or Atlas cluster + * ({@code morphium.replica-set-name}) — single-node standalone MongoDB does not + * support multi-document transactions. Requires {@code spring-boot-starter-aop} on + * the classpath for the aspect to be woven in; see {@link MorphiumTransactionAspect} + * for the exact activation conditions. + * + *

    {@code
    + * @MorphiumTransactional
    + * public void placeOrder(Order order, Payment payment) {
    + *     morphium.store(order);
    + *     morphium.store(payment);
    + * }
    + * }
    + */ +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +public @interface MorphiumTransactional { +} diff --git a/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 000000000..ac0012213 --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,3 @@ +de.caluga.morphium.spring.autoconfigure.MorphiumAutoConfiguration +de.caluga.morphium.spring.autoconfigure.MorphiumHealthAutoConfiguration +de.caluga.morphium.spring.autoconfigure.MorphiumTransactionAspect diff --git a/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/MorphiumAutoConfigurationTest.java b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/MorphiumAutoConfigurationTest.java new file mode 100644 index 000000000..fda61d400 --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/MorphiumAutoConfigurationTest.java @@ -0,0 +1,29 @@ +package de.caluga.morphium.spring.autoconfigure; + +import de.caluga.morphium.Morphium; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(classes = TestApplication.class) +@ActiveProfiles("test") +class MorphiumAutoConfigurationTest { + + @Autowired + Morphium morphium; + + @Test + void morphiumBeanIsCreated() { + assertNotNull(morphium); + assertEquals("test", morphium.getConfig().connectionSettings().getDatabase()); + } + + @Test + void driverIsInMemory() { + assertTrue(morphium.getDriver().getClass().getSimpleName().contains("InMem"), + "Expected InMemDriver but got: " + morphium.getDriver().getClass().getName()); + } +} diff --git a/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/MorphiumRepositoryProxyTest.java b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/MorphiumRepositoryProxyTest.java new file mode 100644 index 000000000..b6e0938ac --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/MorphiumRepositoryProxyTest.java @@ -0,0 +1,229 @@ +package de.caluga.morphium.spring.autoconfigure; + +import de.caluga.morphium.Morphium; +import jakarta.data.Sort; +import jakarta.data.page.Page; +import jakarta.data.page.PageRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(classes = TestApplication.class) +@ActiveProfiles("test") +class MorphiumRepositoryProxyTest { + + @Autowired + TestEntityRepository repository; + + @Autowired + Morphium morphium; + + @BeforeEach + void cleanUp() { + morphium.clearCollection(TestEntity.class); + } + + @Test + void repositoryIsInjected() { + assertNotNull(repository); + } + + @Test + void saveAndFindById() { + var entity = new TestEntity("test", "active", 1); + var saved = (TestEntity) repository.save(entity); + assertNotNull(saved.getId()); + + var found = repository.findById(saved.getId()); + assertTrue(found.isPresent()); + assertEquals("test", ((TestEntity) found.get()).getName()); + } + + @Test + void findByStatus() { + repository.save(new TestEntity("a", "active", 1)); + repository.save(new TestEntity("b", "active", 2)); + repository.save(new TestEntity("c", "inactive", 3)); + + List active = repository.findByStatus("active"); + assertEquals(2, active.size()); + } + + @Test + void findByStatusAndPriority() { + repository.save(new TestEntity("a", "active", 1)); + repository.save(new TestEntity("b", "active", 2)); + repository.save(new TestEntity("c", "active", 1)); + + List result = repository.findByStatusAndPriority("active", 1); + assertEquals(2, result.size()); + } + + @Test + void countByStatus() { + repository.save(new TestEntity("a", "active", 1)); + repository.save(new TestEntity("b", "active", 2)); + repository.save(new TestEntity("c", "inactive", 3)); + + assertEquals(2, repository.countByStatus("active")); + assertEquals(1, repository.countByStatus("inactive")); + } + + @Test + void deleteById() { + var entity = new TestEntity("test", "active", 1); + var saved = (TestEntity) repository.save(entity); + + repository.deleteById(saved.getId()); + + var found = repository.findById(saved.getId()); + assertTrue(found.isEmpty()); + } + + @Test + void morphiumAccessViaMorphiumRepository() { + assertNotNull(repository.morphium()); + assertSame(morphium, repository.morphium()); + } + + @Test + void queryAccessViaMorphiumRepository() { + repository.save(new TestEntity("test", "active", 1)); + + var query = repository.query(); + assertNotNull(query); + assertEquals(1, query.countAll()); + } + + // -- Regression: @Find methods must honor @By parameter bindings, not @Param -- + + @Test + void findWithByAnnotationBindsTheAnnotatedField() throws Exception { + repository.save(new TestEntity("a", "active", 1)); + repository.save(new TestEntity("b", "active", 2)); + repository.save(new TestEntity("c", "inactive", 3)); + + // buildConditionsSpec() previously read @Param (never present here) or the + // reflected parameter name -- without -parameters that name is "arg0", so the + // condition became "arg0:0" instead of "status:0" and matched nothing. + List active = repository.byStatus("active"); + assertEquals(2, active.size()); + } + + // -- Regression: derived query methods returning CompletionStage must actually + // run asynchronously, not throw ClassCastException on the raw sync result -- + + @Test + void derivedQueryWithCompletionStageReturnTypeExecutesAsynchronously() throws Exception { + repository.save(new TestEntity("a", "active", 1)); + repository.save(new TestEntity("b", "active", 2)); + repository.save(new TestEntity("c", "inactive", 3)); + + CompletionStage> stage = repository.findByStatusAsync("active"); + List active = stage.toCompletableFuture().get(5, TimeUnit.SECONDS); + assertEquals(2, active.size()); + } + + // ---- Review finding 1: derived queries dropped dynamic Sort/PageRequest ---- + + @Test + void derivedQueryHonoursDynamicSortArgument() { + repository.save(new TestEntity("low", "active", 1)); + repository.save(new TestEntity("high", "active", 9)); + repository.save(new TestEntity("mid", "active", 5)); + + // Without the fix the Sort argument was silently dropped: no exception, but the + // result came back in insertion order. Assert the ORDER, not just the size. + List desc = repository.findByStatus("active", Sort.desc("priority")); + assertEquals(3, desc.size()); + assertEquals(List.of(9, 5, 1), desc.stream().map(TestEntity::getPriority).toList()); + + List asc = repository.findByStatus("active", Sort.asc("priority")); + assertEquals(List.of(1, 5, 9), asc.stream().map(TestEntity::getPriority).toList()); + } + + @Test + void derivedQueryWithPageReturnTypeYieldsAPage() { + for (int i = 1; i <= 5; i++) { + repository.save(new TestEntity("e" + i, "active", i)); + } + + // Without the fix this threw ClassCastException: the simple bridge overload + // returned a plain ArrayList where the proxy expected a Page. + Page page = repository.findByStatus("active", PageRequest.ofSize(2)); + assertNotNull(page); + assertEquals(2, page.content().size()); + } + + // ---- Review finding 2: @Delete with a numeric return type returned null ---- + + @Test + void annotatedDeleteWithLongReturnTypeReturnsTheDeleteCount() { + repository.save(new TestEntity("a", "active", 1)); + repository.save(new TestEntity("b", "active", 2)); + repository.save(new TestEntity("c", "inactive", 3)); + + // Without the fix the void bridge ran and the handler returned null, which blew up + // as a NullPointerException unboxing null into the primitive long return value. + long deleted = repository.deleteCountedByStatus("active"); + assertEquals(2, deleted); + + // ... and the rows really are gone, not just counted. + assertEquals(0, repository.findByStatus("active").size()); + assertEquals(1, repository.findByStatus("inactive").size()); + } + + // ---- Review finding 3: @Query / @Find with CompletionStage ran synchronously ---- + + @Test + void jdqlQueryWithCompletionStageReturnTypeYieldsAList() throws Exception { + repository.save(new TestEntity("a", "active", 1)); + repository.save(new TestEntity("b", "active", 2)); + repository.save(new TestEntity("c", "inactive", 3)); + + CompletionStage> stage = repository.queryByStatusAsync("active"); + Object result = stage.toCompletableFuture().get(5, TimeUnit.SECONDS); + + // Two distinct defects were possible here. Without the async branch the proxy threw + // ClassCastException outright. With the async branch but a returnsSingle that still + // ignored CompletionStage, the stage completed with ONE entity instead of a list -- + // no exception, wrong result. Assert the shape explicitly to catch both. + assertInstanceOf(List.class, result, "stage must complete with a List, not a single entity"); + assertEquals(2, ((List) result).size()); + } + + @Test + void annotatedFindWithCompletionStageReturnTypeYieldsAList() throws Exception { + repository.save(new TestEntity("a", "active", 1)); + repository.save(new TestEntity("b", "active", 2)); + repository.save(new TestEntity("c", "inactive", 3)); + + CompletionStage> stage = repository.findAsyncByStatus("active"); + Object result = stage.toCompletableFuture().get(5, TimeUnit.SECONDS); + + assertInstanceOf(List.class, result, "stage must complete with a List, not a single entity"); + assertEquals(2, ((List) result).size()); + } + + // ---- Review finding 4: default methods hit "Unsupported repository method" ---- + + @Test + void defaultMethodRunsItsOwnImplementation() { + repository.save(new TestEntity("a", "active", 1)); + repository.save(new TestEntity("b", "active", 2)); + repository.save(new TestEntity("c", "inactive", 3)); + + // Deliberately named countBy* so this also proves the isDefault() check wins over + // derived-query parsing. Without the fix: UnsupportedOperationException. + assertEquals(2, repository.countByStatusViaDefaultMethod("active")); + assertEquals(1, repository.countByStatusViaDefaultMethod("inactive")); + } +} diff --git a/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/MorphiumTransactionAspectTest.java b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/MorphiumTransactionAspectTest.java new file mode 100644 index 000000000..03f4e2805 --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/MorphiumTransactionAspectTest.java @@ -0,0 +1,95 @@ +package de.caluga.morphium.spring.autoconfigure; + +import de.caluga.morphium.Morphium; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Regression tests for {@link MorphiumTransactionAspect}. Verifies that the aspect is + * actually registered as a Spring bean (it previously relied on component scan, + * which never finds it when this module is used as an external starter dependency + * outside its own package — see the class-level documentation on + * {@link MorphiumTransactionAspect} for the fix), and that + * {@code @MorphiumTransactional} methods actually run inside a transaction. + */ +@SpringBootTest(classes = TestApplication.class) +@ActiveProfiles("test") +class MorphiumTransactionAspectTest { + + @Autowired(required = false) + MorphiumTransactionAspect aspect; + + @Autowired + TransactionalTestService service; + + @Autowired + Morphium morphium; + + @BeforeEach + void cleanUp() { + morphium.clearCollection(TestEntity.class); + } + + @Test + void aspectBeanIsRegistered() { + // Regression: previously a plain @Component, never picked up by component + // scan for a real application depending on this module as an external jar. + assertNotNull(aspect, "MorphiumTransactionAspect must be registered as an " + + "auto-configuration bean, not rely on component scan"); + } + + @Test + void transactionalMethodCommitsOnNormalReturn() { + service.saveWithinTransaction(new TestEntity("a", "active", 1)); + + assertEquals(1, morphium.createQueryFor(TestEntity.class).countAll()); + } + + @Test + void transactionalMethodAbortsOnException() { + assertThrows(IllegalStateException.class, + () -> service.saveThenThrow(new TestEntity("a", "active", 1))); + + // InMemDriver's abortTransaction() rolls back writes made within the + // transaction -- if the aspect were never woven in (the original bug), the + // store() call would have committed outside any transaction and this + // document would still be present. + assertEquals(0, morphium.createQueryFor(TestEntity.class).countAll()); + } + + // ---- Review finding 6: nested @MorphiumTransactional lost the outer transaction ---- + + @Test + void nestedTransactionalCallJoinsTheOuterTransaction() { + // The inner call goes through a second proxied bean, so the aspect really runs twice. + // Without REQUIRED propagation the inner startTransaction() threw + // IllegalArgumentException ("transaction in progress"), that exception propagated into + // the outer advice's catch, and the outer transaction was aborted -- so NEITHER + // document survived. Both must be present now. + service.saveOuterThenNestedInner( + new TestEntity("outer", "active", 1), + new TestEntity("inner", "active", 2)); + + assertEquals(2, morphium.createQueryFor(TestEntity.class).countAll()); + } + + @Test + void nestedTransactionalRollsBackBothOnInnerFailure() { + // The inner method throws while joined to the outer transaction. The exception must + // reach the caller, and because the inner call neither committed nor aborted on its + // own, the outer advice's abort has to roll back the outer AND the inner write. + assertThrows(IllegalStateException.class, + () -> service.saveOuterThenNestedInnerThrows( + new TestEntity("outer", "active", 1), + new TestEntity("inner", "active", 2))); + + assertEquals(0, morphium.createQueryFor(TestEntity.class).countAll()); + } +} diff --git a/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/NestedTransactionalTestService.java b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/NestedTransactionalTestService.java new file mode 100644 index 000000000..913d2fde9 --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/NestedTransactionalTestService.java @@ -0,0 +1,30 @@ +package de.caluga.morphium.spring.autoconfigure; + +import de.caluga.morphium.Morphium; +import org.springframework.stereotype.Service; + +/** + * Second transactional bean, so {@link TransactionalTestService} can nest a + * {@code @MorphiumTransactional} call through a real Spring proxy rather than a + * self-invocation (which would bypass the aspect entirely and prove nothing). + */ +@Service +public class NestedTransactionalTestService { + + private final Morphium morphium; + + public NestedTransactionalTestService(Morphium morphium) { + this.morphium = morphium; + } + + @MorphiumTransactional + public void saveInner(TestEntity entity) { + morphium.store(entity); + } + + @MorphiumTransactional + public void saveInnerThenThrow(TestEntity entity) { + morphium.store(entity); + throw new IllegalStateException("forced failure inside the nested transaction"); + } +} diff --git a/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/TestApplication.java b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/TestApplication.java new file mode 100644 index 000000000..7802f66d8 --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/TestApplication.java @@ -0,0 +1,8 @@ +package de.caluga.morphium.spring.autoconfigure; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +@EnableMorphiumRepositories +public class TestApplication { +} diff --git a/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/TestEntity.java b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/TestEntity.java new file mode 100644 index 000000000..0b94d4279 --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/TestEntity.java @@ -0,0 +1,31 @@ +package de.caluga.morphium.spring.autoconfigure; + +import de.caluga.morphium.annotations.Entity; +import de.caluga.morphium.annotations.Id; +import de.caluga.morphium.driver.MorphiumId; + +@Entity +public class TestEntity { + @Id + private MorphiumId id; + private String name; + private String status; + private int priority; + + public TestEntity() {} + + public TestEntity(String name, String status, int priority) { + this.name = name; + this.status = status; + this.priority = priority; + } + + public MorphiumId getId() { return id; } + public void setId(MorphiumId id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + public int getPriority() { return priority; } + public void setPriority(int priority) { this.priority = priority; } +} diff --git a/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/TestEntityRepository.java b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/TestEntityRepository.java new file mode 100644 index 000000000..3b684dfc4 --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/TestEntityRepository.java @@ -0,0 +1,64 @@ +package de.caluga.morphium.spring.autoconfigure; + +import de.caluga.morphium.data.MorphiumRepository; +import de.caluga.morphium.driver.MorphiumId; +import jakarta.data.Sort; +import jakarta.data.page.Page; +import jakarta.data.page.PageRequest; +import jakarta.data.repository.By; +import jakarta.data.repository.Delete; +import jakarta.data.repository.Find; +import jakarta.data.repository.Query; +import jakarta.data.repository.Repository; + +import java.util.List; +import java.util.concurrent.CompletionStage; + +@Repository +public interface TestEntityRepository extends MorphiumRepository { + + List findByStatus(String status); + + List findByStatusAndPriority(String status, int priority); + + long countByStatus(String status); + + CompletionStage> findByStatusAsync(String status); + + @Find + List byStatus(@By("status") String status); + + // --- Regression coverage: dynamic Sort/PageRequest on a derived query (review finding 1) --- + + /** A dynamic {@link Sort} argument must actually reach the query, not be dropped. */ + List findByStatus(String status, Sort sort); + + /** A {@link Page} return type requires the paging-aware bridge overload. */ + Page findByStatus(String status, PageRequest pageRequest); + + // --- Regression coverage: @Delete with a numeric return type (review finding 2) --- + + /** Jakarta Data permits void/int/long here; the numeric variants return the delete count. */ + @Delete + long deleteCountedByStatus(@By("status") String status); + + // --- Regression coverage: CompletionStage on @Query and @Find (review finding 3) --- + + /** Must resolve to the async JDQL bridge and yield a LIST, not a single entity. */ + @Query("WHERE status = :status") + CompletionStage> queryByStatusAsync(@jakarta.data.repository.Param("status") String status); + + /** Must resolve to the async find bridge and yield a LIST, not a single entity. */ + @Find + CompletionStage> findAsyncByStatus(@By("status") String status); + + // --- Regression coverage: default method dispatch (review finding 4) --- + + /** + * A default method composes other repository calls and must run as written. Deliberately + * named {@code countBy...} to also prove the default check wins over derived-query parsing. + */ + default long countByStatusViaDefaultMethod(String status) { + return findByStatus(status).size(); + } +} diff --git a/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/TransactionalTestService.java b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/TransactionalTestService.java new file mode 100644 index 000000000..72d3ae5da --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/java/de/caluga/morphium/spring/autoconfigure/TransactionalTestService.java @@ -0,0 +1,58 @@ +package de.caluga.morphium.spring.autoconfigure; + +import de.caluga.morphium.Morphium; +import org.springframework.stereotype.Service; + +/** + * Test-only service exercising {@link MorphiumTransactional} through the + * {@link MorphiumTransactionAspect}, to verify the aspect is actually woven in + * when this module is used as an external starter dependency (see + * {@link MorphiumTransactionAspect}'s class-level documentation for why a plain + * {@code @Component} would not have been picked up in that scenario). + */ +@Service +public class TransactionalTestService { + + private final Morphium morphium; + private final NestedTransactionalTestService nested; + + public TransactionalTestService(Morphium morphium, NestedTransactionalTestService nested) { + this.morphium = morphium; + this.nested = nested; + } + + @MorphiumTransactional + public void saveWithinTransaction(TestEntity entity) { + morphium.store(entity); + } + + @MorphiumTransactional + public void saveThenThrow(TestEntity entity) { + morphium.store(entity); + throw new IllegalStateException("forced failure to exercise abortTransaction()"); + } + + /** + * Outer transactional method calling a second {@code @MorphiumTransactional} bean. + * The inner call goes through the injected proxy (not {@code this}), so the aspect + * really does run twice - which is exactly the nesting case REQUIRED propagation has + * to survive. Without it, the inner {@code startTransaction()} throws and the outer + * transaction is aborted, losing {@code outer} as well. + */ + @MorphiumTransactional + public void saveOuterThenNestedInner(TestEntity outer, TestEntity inner) { + morphium.store(outer); + nested.saveInner(inner); + } + + /** + * Same nesting, but the inner method throws. The exception must propagate out of the + * outer method and the outer work must be rolled back - the inner call must not have + * committed anything on its own. + */ + @MorphiumTransactional + public void saveOuterThenNestedInnerThrows(TestEntity outer, TestEntity inner) { + morphium.store(outer); + nested.saveInnerThenThrow(inner); + } +} diff --git a/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/resources/application-test.properties b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/resources/application-test.properties new file mode 100644 index 000000000..47511870e --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-autoconfigure/src/test/resources/application-test.properties @@ -0,0 +1,3 @@ +morphium.database=test +morphium.driver-name=InMemDriver +morphium.hosts=localhost:27017 diff --git a/spring-boot-morphium/morphium-spring-boot-starter/pom.xml b/spring-boot-morphium/morphium-spring-boot-starter/pom.xml new file mode 100644 index 000000000..f3aabe0e8 --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-starter/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + + de.caluga + morphium-spring-boot-parent + 6.3.2-SNAPSHOT + + + morphium-spring-boot-starter + Morphium Spring Boot – Starter + Starter POM for Spring Boot Morphium integration + + + + de.caluga + morphium-spring-boot-autoconfigure + ${project.version} + + + de.caluga + morphium + ${project.version} + + + de.caluga + morphium-jakarta-data + ${project.version} + + + jakarta.data + jakarta.data-api + + + + + + + + org.apache.maven.plugins + maven-source-plugin + + + org.apache.maven.plugins + maven-javadoc-plugin + + + + diff --git a/spring-boot-morphium/morphium-spring-boot-starter/src/main/java/de/caluga/morphium/spring/starter/package-info.java b/spring-boot-morphium/morphium-spring-boot-starter/src/main/java/de/caluga/morphium/spring/starter/package-info.java new file mode 100644 index 000000000..76772df9e --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-starter/src/main/java/de/caluga/morphium/spring/starter/package-info.java @@ -0,0 +1,17 @@ +/** + * Marker package for the {@code morphium-spring-boot-starter} artifact. + * + *

    This starter is intentionally an empty jar: it exists only to pull in the + * {@code morphium-spring-boot-autoconfigure} module and its transitive dependencies + * with a single Maven coordinate, following Spring Boot's own starter convention + * (see {@code spring-boot-starter-web} and similar). All auto-configuration classes, + * {@code @ConfigurationProperties}, and repository infrastructure live in + * {@code morphium-spring-boot-autoconfigure} instead. + * + *

    This package-info exists solely so that {@code maven-javadoc-plugin} and + * {@code maven-source-plugin} have at least one compilation unit to process — + * Maven Central requires a {@code -sources.jar} and {@code -javadoc.jar} for every + * published artifact, and both plugins otherwise silently produce no jar at all + * when a module's source tree is completely empty. + */ +package de.caluga.morphium.spring.starter; diff --git a/spring-boot-morphium/morphium-spring-boot-test/pom.xml b/spring-boot-morphium/morphium-spring-boot-test/pom.xml new file mode 100644 index 000000000..e56175a17 --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-test/pom.xml @@ -0,0 +1,47 @@ + + + 4.0.0 + + + de.caluga + morphium-spring-boot-parent + 6.3.2-SNAPSHOT + + + morphium-spring-boot-test + Morphium Spring Boot – Test Support + + + + de.caluga + morphium-spring-boot-autoconfigure + ${project.version} + + + org.springframework.boot + spring-boot-test-autoconfigure + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + + org.apache.maven.plugins + maven-source-plugin + + + org.apache.maven.plugins + maven-javadoc-plugin + + + + diff --git a/spring-boot-morphium/morphium-spring-boot-test/src/main/java/de/caluga/morphium/spring/test/MorphiumTest.java b/spring-boot-morphium/morphium-spring-boot-test/src/main/java/de/caluga/morphium/spring/test/MorphiumTest.java new file mode 100644 index 000000000..726a26373 --- /dev/null +++ b/spring-boot-morphium/morphium-spring-boot-test/src/main/java/de/caluga/morphium/spring/test/MorphiumTest.java @@ -0,0 +1,29 @@ +package de.caluga.morphium.spring.test; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import java.lang.annotation.*; + +/** + * Composite test annotation that configures a Spring Boot test with the Morphium + * in-memory driver. No MongoDB instance required. + * + *

    + * {@code @MorphiumTest}
    + * class MyRepositoryTest {
    + *     {@code @Autowired} MyRepository repo;
    + * }
    + * 
    + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@SpringBootTest +@TestPropertySource(properties = { + "morphium.database=test", + "morphium.driver-name=InMemDriver", + "morphium.hosts=localhost:27017" +}) +public @interface MorphiumTest { +} diff --git a/spring-boot-morphium/pom.xml b/spring-boot-morphium/pom.xml new file mode 100644 index 000000000..c947ebaf8 --- /dev/null +++ b/spring-boot-morphium/pom.xml @@ -0,0 +1,55 @@ + + + 4.0.0 + + + de.caluga + morphium-parent + 6.3.2-SNAPSHOT + + + morphium-spring-boot-parent + pom + + Morphium Spring Boot – Parent + Spring Boot integration for Morphium MongoDB ODM with Jakarta Data repository support + https://github.com/Bardioc1977/spring-boot-morphium + + + + The Apache Software License, Version 2.0 + http://www.apache.org/licenses/LICENSE-2.0.txt + + + + + morphium-spring-boot-autoconfigure + morphium-spring-boot-starter + morphium-spring-boot-test + + + + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring-boot.version} + pom + import + + + +