fix(driver): send the read preference the caller asked for - #362
fix(driver): send the read preference the caller asked for#362bernhgen wants to merge 2 commits into
Conversation
MongoCommand had $readPreference hardcoded to primaryPreferred with no way to set it, and asMap() serialises it into every command. On a replica set this is invisible: the driver picks the node itself and mongod ignores the field. Behind a mongos it is decisive - a sharded cluster routes reads by exactly this field - so every read is primaryPreferred there, no matter what MorphiumConfig.defaultReadPreference or @DefaultReadPreference say. The read preference is now resolved as: the one set on the command, else the one the connection was handed out for, else primaryPreferred as before. A connection now carries the read preference it was selected for, so commands built on it are sent with it without every call site passing it on. In PooledDriver the PRIMARY-forcing rules (transaction, read-after-write window, in-memory backend) moved out of getReadConnection() into effectiveReadPreference(), so what the connection is marked with is what the read is really performed with.
reread() exists to refresh an object with the state that is in the database. Reading that from a secondary defeats the purpose: one that has not caught up yet answers with an older version of the document - or with nothing at all, in which case reread() returns null for a document that is there, which the caller can only read as "deleted". It now always asks for the primary, like the driver already does inside a transaction and in the read-after-write window.
sboesebeck
left a comment
There was a problem hiding this comment.
Hi Bernhard,
thanks for this one - the analysis is spot on, and the mongos angle is something I had honestly never looked at. Nice catch. I ran the new tests locally (19/19 green, CommandAsMapTest still happy) and then went through the driver side with a fine comb. Came out longer than I expected, but nothing here is big. In order of importance:
1. SingleMongoConnectDriver: the stamp gets lost
getConnection() always hands out a ConnectionWrapper (line 154/174), and that wrapper does not override get/setEffectiveReadPreference(). So the stamp in getReadConnection()/getPrimaryConnection() hits the no-op default of the interface and is gone. readPreferenceAsDoc() sees null and sends primaryPreferred exactly as before - the fix is inert on one of the three drivers, and no test would notice (MongoCommandReadPreferenceTest only uses ConnectionMock and InMemoryDriver). Delegating both methods to getDelegate() fixes it.
But careful, once that works two things surface:
- With
ConnectionType.SECONDARYorANYthe driver sits on a secondary on purpose. DriverBase defaultsdefaultRPtoprimary(), so a read without an explicit preference goes out asmode: "primary"- and the secondary answers 13435. mongod does that too, it uses$readPreferencefor the secondaryOk decision (so "mongod ignores it" in the PR text is not quite right). The server selection spec says for exactly this case: on a direct connection to a secondary, sendprimaryPreferredif the mode is primary. Please do that whenconnectionType != PRIMARY. SingleMongoConnectDriverstamps the raw preference without the PRIMARY-forcing rules (transaction, read-after-write window, inMemoryBackend) thatPooledDriver.effectiveReadPreference()has. Inside a transaction that would sendmode: nearestto a mongos, which rejects it. All three inputs live in DriverBase anyway - I'd moveeffectiveReadPreference()down there and call it from both drivers.
2. The test withoutARequestedOneThePrimaryIsUsed
That one pins down that PooledDriver.getDefaultReadPreference() hardcodes PRIMARY and ignores setDefaultReadPreference(). That is a known wart on my list, not behaviour I want to keep - the moment I fix it, your test goes red. Please drop it, or assert against getDefaultReadPreference() instead of the PRIMARY literal.
3. Tags with mode primary
readPreferenceAsDoc() emits tags for every mode, PRIMARY included. MongoDB rejects that ("Only empty tags are allowed with primary mode"), and ReadPreference.addTag() has no type check, so primary() plus a tag was harmless until now and breaks every read after this PR. Skip the tags when the type is PRIMARY.
4. Failover window - a behaviour change we should know about
Reads with mode: "primary" (PooledDriver default, transactions, read-after-write window, every PoppyDB read, reread()) that hit the ex-primary right after a step-down now get 13435 instead of a silent, possibly stale read. primaryNode stays stale until the next heartbeat (1s), and the read path has no retry for that code - only WriteMongoCommand.isStepDownError() knows it. I think the error is the right answer, but it is new. I'll run the mongodb_rs and poppydb_rs phases on the testrunner once the rest is in and we see what falls out. If it gets noisy, a read-side counterpart to isStepDownError() is probably the way.
Related: on a reconnect after a network error, ReadMongoCommand re-borrows with getDefaultReadPreference() (= primary), not with what the query asked for. Behind a mongos the retry is then routed differently than the first attempt. Cheap fix: put the requested preference on the command (setReadPreference() - you added it, nobody calls it yet) and re-borrow with that.
5. The default is nearest()
DriverSettings ships with nearest(). On a replica set that was already effective through node selection, on a mongos it was always primaryPreferred - and now becomes nearest. So an unconfigured sharded deployment silently loses read-your-own-writes after a plain store(). That is our default, not your bug, but now that the field is wire-effective it is the wrong one. Please switch the default in DriverSettings to primaryPreferred() in this PR - that keeps mongos exactly where it was, and on a replica set it means unconfigured setups read from the primary, like the official drivers do. Needs a ### Changed entry in the CHANGELOG, since RS users who relied on the implicit nearest will notice the load shift.
6. Small stuff
- CHANGELOG entry under
[Unreleased]/### Fixedplease, with the why (mongos routing) - that is how we do it here. Same place forreread()now always reading from the primary (and throwing after serverSelectionTimeout if none is reachable), and for the PoppyDB 13435 change you already described in the PR. - The comment in PoppyDB's
MongoCommandHandler(around line 1154) and the javadoc ofSecondaryReadPreferenceTeststill claim morphium always sendsprimaryPreferred. You mentioned it yourself, please just update both. effectiveReadPreference()normalisesrp == nullbut notrp.getType() == null, andselectReadConnection()switches on the type.new ReadPreference()is public and DriverSettings tolerates a null type, so that is an NPE waiting. Pre-existing hole, but you're standing right next to it - a one-liner.fromMap()/asMap()still skip a field namedreadPreferenceby name; with@Transientthat is redundant, and the Javadoc says "(by name)". Your call.- The
primaryPreferred()fallback ineffectiveReadPreference()cannot be reached (getDefaultReadPreference()never returns null) and encodes a different default than the documented primary. Same for theInMemoryDriverternary -getDefaultReadPreference()there always returns null.
That's it. Fix 1 to 3, flip the default, add the changelog, then I kick off the testrunner and we take it from there.
Viele Grüße,
Stephan
|
Follow-up on point 4, now with a CI data point. The first CI run of this PR went red on I ran the test locally on both branches:
So the error storm is caused by the PR (by design - the stale ex-leader is the wrong sync source, and the error is the right answer), and the assertion failure on top of it is timing: with the election churn on the CI box the test fetches the ReplicationManager a second time after its poll and may get a fresh instance whose clear-counter is still 0. The CI rerun of the same commit went green, which fits. Nothing more to change in this PR for that. Two things I'll take on our side, separate from your PR: the ReplicationManager should treat 13435 as "sync source is no longer primary" and wait for the leadership change instead of retry-looping, and the test needs to hold on to the instance it polled. I'll open issues for both once your revision is in and we've seen the testrunner phases. |
Problem
MongoCommandhardcodes the$readPreferencefield it sends to the server:There is no setter and no way to derive it from the caller's read preference, and
asMap()serialises the field into every command.
On a plain replica set this is invisible: the driver picks the node itself in
getReadConnection(ReadPreference), andmongodignores$readPreferencein the command.Behind a mongos it is decisive — a sharded cluster routes reads by exactly this field. So
on a sharded cluster every read is effectively
primaryPreferred, no matter what wasconfigured:
MorphiumConfig.defaultReadPreferenceand@DefaultReadPreferenceon an entityare silently dropped.
Note that on a sharded cluster the read preference cannot take effect through node selection
either:
replicaSetis only set fromhello.setName(PooledDriver), a mongos does not reportone, so
selectReadConnection()takes its standalone branch and never looks at the readpreference. The command field is the only mechanism there.
The practical consequence is stale reads that the application explicitly asked to avoid. With
primaryPreferred, a brief primary loss on a shard makes mongos read from a secondary; onethat has not caught up answers with an older version of a document — or with nothing at all,
which makes
Morphium.reread()returnnullfor a document that exists.Second, smaller issue:
Morphium.reread()reads with the default read preference. Its purposeis to refresh an object with the state that is in the database, so a possibly lagging
secondary is the wrong source — the driver already forces the primary inside transactions and
in the read-after-write window, but not here.
Both verified in 6.2.10 and in
develop.Fix
1.
fix(driver): send the read preference the caller asked forMongoCommandgets a transientreadPreferencewith getter/setter; the hardcoded$readPreferencefield is gone.asMap()resolves it as: set on the command → the one theconnection was handed out for →
primaryPreferredas before, so the wire output does notchange when nobody asks for anything.
ReadPreferenceType.getMode()returns the wire-protocol names; a tag set is emitted astags: [ { … } ].MongoConnectiongetsgetEffectiveReadPreference()/setEffectiveReadPreference()asdefault methods (no-op, so third-party implementations keep compiling).
PooledDriver,SingleMongoConnectDriverandInMemoryDriverstamp the connection they hand out.PooledDriverthe PRIMARY-forcing rules (transaction, read-after-write window, in-memorybackend) move out of
getReadConnection()intoeffectiveReadPreference(), so what theconnection is marked with is what the read is really performed with.
2.
fix(morphium): reread() reads from the primaryMorphium.reread()usesgetReadConnection(ReadPreference.primary()).Tests
MongoCommandReadPreferenceTest(12) — what ends up in$readPreference: set on thecommand, inherited from the connection, command wins over connection, the unchanged default,
every type's wire name, tag sets, and both ends of the chain (a connection handed out for a
read preference carries it into the command; a write connection is marked PRIMARY).
PooledDriverEffectiveReadPreferenceTest(4) — the effective read preference: requested onekept, PRIMARY without a requested one, PRIMARY forced during a transaction, tag set survives.
RereadReadPreferenceTest(1) —reread()asks for PRIMARY even with anearestdefault.Green alongside these: the
driverpackage (796),CommandAsMapTest, the reread/transactiontests under
-Pinmem, and poppydb'sSecondaryReadPreferenceTest.One thing for the reviewer
poppydb'sMongoCommandHandlerrejectsmode: "primary"reads on a secondary with 13435,and its comment relies on morphium always sending
primaryPreferred. With this change thatassumption no longer holds, so a read against a secondary that explicitly asks for the primary
now gets the error mongod would return too — correct, but a behaviour change worth naming.