matrei commented on PR #16369:
URL: https://github.com/apache/grails-core/pull/16369#issuecomment-5780716262
## Review Findings, round 2
Head `190ac9c388`, base `8.0.x` at `1fadd5c812`; the branch still merges
clean against the current `8.0.x` (`848d29e0ce`). This round covers everything
since `076ff09324`: the #16368 review changes (`b34471f68c`, `5e5b35a599`,
merged in through `9e0312eb15`), this PR's review changes (`2d8770e5cd`), and
the new `@Transactional(connection)` routing (`190ac9c388`). CI on the head was
still running when I wrote this (4 passed, 67 pending, none failed); the
earlier `Update Release Draft` non-green on `9e0312eb15` was *cancelled* with
no steps run, which is what that workflow does on a fork PR.
What I ran on the head, all green (build cache off, `cleanTest` first,
result XML timestamps from this run):
- `:grails-datamapping-core-test:test`, the whole module: 623 tests, 0
failures, including the new `ConnectionScopeMultiTenancySpec` (2) and the ten
in `MultipleDataSourceSpec`.
- `:grails-datamapping-core:test`, the whole module (the transform lives
here): 1 082 tests, 0 failures.
- `:grails-data-hibernate5-core:test` for
`MultipleDataSourceConnectionsSpec` and `Hibernate5RefreshLockSpec`: 7 + 103
tests, 0 failures.
- `:grails-data-hibernate7-core:test` for
`MultipleDataSourceConnectionsSpec` and `Hibernate7RefreshLockSpec`: 7 + 102
tests, 0 failures.
- `:grails-data-mongodb-core:test --tests MultipleConnectionsSpec`: 8 tests,
0 failures, against a real container.
- `codeStyle` on `grails-datamapping-core` and `grails-data-mongodb-core`:
no violations.
- Two throwaway specs on `SimpleMapDatastore`, deleted afterwards: one for
the multi-tenant numbers under Confirmed and the two nits, and one run on both
`190ac9c388` and `9e0312eb15` for the before/after numbers in the two P3s.
Every finding from the previous rounds is addressed the way the replies
describe, the resolution change in `b34471f68c` is the simplification I
suggested, made correctly, and the `@Transactional(connection)` routing does
what the description says for the case it describes: a method under
`@Transactional(connection = 'books')`, or the `books` datastore's
`TransactionService`, now routes the classes mapped to `books` there. Nothing
blocks the merge. The two P3s are about what `190ac9c388` does beyond that
case, and both have a small fix.
### [P3] `TransactionService` on the *default* datastore now undoes an
enclosing block; `@Transactional` without a connection does not
References:
-
`grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/services/DefaultTransactionService.groovy:128-135`
-
`grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormRegistry.groovy:534-541`,
`:583-590`
-
`grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/transactions/transform/TransactionalTransform.groovy:389-391`
`newTemplate` sets `connectionName` to
`resolveDefaultConnectionSourceName(datastore)` for *every* datastore, and for
the root datastore that is `DEFAULT`. `runInScope(null, DEFAULT)` inside
another block pushes a connection-wide scope that `isMappedToConnection`
applies to every entity, so the root datastore's `TransactionService` now
undoes whatever block it is called from. The transform, by contrast, passes a
connection only when the annotation names one, so a plain `@Transactional`
method in the same position keeps the block's routing. On a
`SimpleMapDatastore` with `Player` mapped to `default` and `one`, one row in
`default` and two in `one`:
```groovy
Player.one.withTransaction { plainService.countPlayers() }
// @Transactional, no connection
// 9e0312eb15: 2 190ac9c388: 2
Player.one.withTransaction {
datastore.getService(TransactionService).withTransaction { Player.count() } }
// 9e0312eb15: 2 190ac9c388: 1
```
The commit message only claims the routing for "the `TransactionService` of
a named connection's datastore", so the default case looks like a side effect
rather than a decision, and it is not in any doc. It reaches application code
that injects `TransactionService` (the `StudentService` in both
`grails-data-service` examples does) and `DefaultGormDataFetcher`, which runs
every GraphQL fetch through `datastore.getService(TransactionService)`. The
generated data services are *not* affected: a `@Service(Player)` method inside
the same block read `default` on both heads, so that path already behaved this
way before `190ac9c388`.
The fix that keeps 8.0.x's behaviour for the common case is a one-liner in
`newTemplate`: pass the name only when it is not `DEFAULT`, so the root
datastore's `TransactionService` behaves like an unqualified `@Transactional`.
If the undo is wanted instead, the transform should pass `DEFAULT` for an
unqualified `@Transactional` too, and the guide should say that a
default-connection transaction resets the routing; but that is a bigger
behaviour change for every service, and I would not make it here. Either way,
the third new test in `MultipleDataSourceSpec` only exercises the two services
*outside* any block; add the nested form above so the choice is pinned.
### [P3] "Mapped to the connection" is narrower than what the namespace API
accepts, on every implementation but Hibernate
References:
-
`grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormRegistry.groovy:578-590`,
`:244-258`
-
`grails-data-mongodb/docs/src/docs/asciidoc/multipleDataSources/mongoClientSwitching.adoc:31`
-
`grails-data-neo4j/docs/src/docs/asciidoc/multipleDataSources/boltDriverSwitching.adoc:31`
`isMappedToConnection` reads `entityDatastores`, which holds the connections
an entity *declares* (`ALL` expanded). `getDatastoreDirect`, which every
`Book.one` lookup goes through, has a fallback the new check does not share:
any registered connection whose mapping context contains the entity. On
Hibernate the two agree, because an entity not declared for a datasource is not
in that datasource's `SessionFactory`. On MongoDB, Neo4j and the in-memory
datastore the child datastores share the parent's mapping context, so an entity
with no `connections` mapping is reachable, and storable, through every named
API, and the two block kinds now disagree about it. With `Coach` declared for
nothing, two rows in `default` and one saved through `Coach.one.save(...)`:
```groovy
Coach.one.count() == 1 // reachable
and stored there
Coach.one.withTransaction { Coach.count() } == 1 // the class's
own block routes it
@Transactional('one') ... { Coach.count() } == 2 // the
annotation leaves it on default
```
The commit's reasoning, "rather than send it somewhere it cannot be stored",
is right for Hibernate and wrong for the others, and the MongoDB and Neo4j
pages now promise routing "for every domain class mapped to `moreBooks`"
without saying what mapped means there. Two ways out, and I would take the
first:
- Say "declared in its `connections` mapping, `ALL` included" on the MongoDB
and Neo4j pages and in their upgrade notes, since a MongoDB or Neo4j entity
that uses a named connection without declaring it is exactly the reader who
will be surprised.
- Or make `isMappedToConnection` ask
`getDatastoreDirect(normalizedClassName, qualifier) != null`, so "mapped" means
"reachable through the namespace" on every implementation. The `Coach` test in
`MultipleDataSourceSpec` would then read `[1, 0]` on the in-memory datastore,
which is the consistent answer there, and Hibernate would be unchanged.
Either way, no MongoDB or Neo4j test covers the annotation although both
guides now claim it. `ScopedCompany` in the MongoDB `MultipleConnectionsSpec`
is declared for `test2`, so one `@Transactional(connection = 'test2')` service
with a `save()` and a `count()` would close that.
### Nit: the `default`-scope assertion in `ConnectionScopeMultiTenancySpec`
never pushes a scope
References:
-
`grails-datamapping-core-test/src/test/groovy/grails/gorm/tests/ConnectionScopeMultiTenancySpec.groovy:63-69`
-
`grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormRegistry.groovy:536-539`
```groovy
and: 'so does a scope for it, rather than the default connection itself'
GormRegistry.withConnectionScope(ScopedTenantBook, ConnectionSource.DEFAULT)
{ ScopedTenantBook.count() } == 2
```
`runInScope` for `DEFAULT` outside any block returns `callable.call()`
without touching the thread-local, so this assertion is
`ScopedTenantBook.count() == 2` under another name and does not reach the
`scopedConnection` branch that `b34471f68c` changed. It would have failed on
`8056ea18db`, which had no short-circuit, so the commit message is right about
that; but the case the label names only happens when `DEFAULT` is pushed
*inside* another block. On the head the nested form reads 2 (tenant `foo`), as
does the public `ScopedTenantBook.bar.withTransaction {
ScopedTenantBook.'default'.withTransaction { ScopedTenantBook.count() } }`,
which is what the javadoc's "undoes an enclosing one" promises for a tenant
entity. Either would do; the public form also tests through the API an
application calls.
### Nit: the "left alone" branch of `inConnectionScope` has no test
References:
-
`grails-datamapping-core/src/main/groovy/org/grails/datastore/gorm/GormStaticApi.groovy:741-742`
The PR body and the javadoc both say a qualifier that is not a connection
name, such as a DISCRIMINATOR tenant id from `withTenant`, is left alone.
Codecov confirms these are the only lines of the scope code nothing reaches,
and no spec has a `withTenant(...).withTransaction { }` or `.withNewSession {
}`. On a DISCRIMINATOR-mode `SimpleMapDatastore` with tenant `foo` current (two
rows) and one row in `bar`, the head gives:
```groovy
ProbeDiscBook.withTenant('bar').count()
== 1
ProbeDiscBook.withTenant('bar').withTransaction { ProbeDiscBook.count() }
== 2 // current tenant, not bar
ProbeDiscBook.withTenant('bar').withNewSession { ProbeDiscBook.count() }
== 2
```
with the stack passing through `inConnectionScope`'s `callable.call()`. The
second and third lines are the test: add them to
`ConnectionScopeMultiTenancySpec` (a second datastore in DISCRIMINATOR mode,
`[ConnectionSource.DEFAULT]` as its connections, an entity with a `String
tenantId`) or to `PartitionMultiTenancySpec`, which has that setup already.
### Confirmed
- The scope stack. `scopedConnection` walks innermost first; a per-entity
entry matches by key, a connection-wide entry by `isMappedToConnection`, and a
connection-wide `DEFAULT` entry matches everything. `pop()` in `finally`
restores the outer state on exception, and the thread-local is removed when the
stack empties. The previous map's `previous`-restore is subsumed: a block for
`Book` on `books` inside a connection-wide `moreBooks` block, with `Book`
mapped to both, resolves `Book` to `books` and everything else to `moreBooks`.
`MultipleDataSourceSpec` still covers the `[1, 2]` nesting and the throw.
- The resolution change in `b34471f68c` is equivalent to the old code for
every case that worked and fixes the one that did not: a non-tenant entity ends
in the same `getDirect`, a tenant entity with a non-default scope hits Priority
1, which is the same `getDirect`, and a tenant entity with a `default` scope,
or none, hits the tenant resolution. The dropped fallback loses nothing,
because `AbstractGormApiRegistry.getDirect` returns `null` only when the entity
has no default API at all.
- On the DATABASE-mode probe (`foo` current with two rows, `bar` with one):
`withConnectionScope(Book, 'bar') { Book.count() }` is 1, the nested `bar {
default { Book.count() } }` is 2, and `Book.withTenant('bar').withTransaction {
Book.count() }` is 1, since a DATABASE tenant id is a connection name and the
tenant's block scopes it, which is the guide's "takes precedence over the
current tenant". `ConnectionScopeMultiTenancySpec`'s second feature covers the
#16368 P3 as now documented.
- The transform passes the annotation's `connection`, or `value`, member
cast to `String` only when one is present; the new three-argument
`GrailsTransactionTemplate` constructor stores it and the old ones delegate
with `null`; `execute` and `executeAndRollback` are both wrapped, so
`@Rollback` follows. A `value` that is a transaction-manager qualifier rather
than a connection name pushes a scope that nothing matches, which is harmless.
- `ALL` is expanded into every connection name by
`GormEnhancer.allQualifiers` before `registerEntityDatastores` runs, so
`isMappedToConnection` is true for each; the Hibernate 5 and 7 `Author` in the
new tests is `datasource 'ALL'`, and its `saveAuthor` under
`@Transactional(connection = "books")` lands in `books` and not in the default
database on both.
- Multi-tenant entities: in DATABASE mode a tenant child datastore's
`TransactionService` pushes the tenant's connection, which the entity resolves
to a child for, so it routes; in DISCRIMINATOR mode tenant qualifiers are
skipped at registration, so no scope applies and nothing changes.
- The merge `9e0312eb15` resolved its three conflicts correctly: `git diff
9e0312eb15^2 9e0312eb15` is exactly this PR's own changes.
- The new `withSession` test in `MultipleDataSourceSpec` and the
`withStatelessSession` test in the MongoDB `MultipleConnectionsSpec`
(`MongoDatastore` is the one `StatelessDatastore` implementation) both exercise
the wrapper they name, and the two new `@Transactional('one')` tests and the
`TransactionService` test check both sides on the in-memory datastore.
- Docs: the guide and both Hibernate guides replace the paragraph that said
the annotation does not route with one that says it does, for the classes
mapped to the datasource, and "its default `DataSource`" for `ZipCode` is
`lookup`, the first mapped one. Section 72 has the silent case, the
cross-connection copy, the annotation and the `Book.'default'` way out. The
MongoDB and Neo4j guides, release notes and upgrade notes all mention the
annotation; the `Transactional.connection()` javadoc says the same. The two
Neo4j errors the release note quotes are real: `The event loop thread may not
be smaller than 1, but was %d.` in driver 4.4.13's `Config$ConfigBuilder` (the
note drops the trailing period) and `Cannot flush write operations without an
active transaction!` at `Neo4jSession.java:906`.
## Verification
- `./gradlew --no-build-cache :grails-datamapping-core-test:cleanTest
:grails-datamapping-core-test:test :grails-datamapping-core:cleanTest
:grails-datamapping-core:test :grails-data-hibernate5-core:cleanTest
:grails-data-hibernate5-core:test --tests MultipleDataSourceConnectionsSpec
--tests Hibernate5RefreshLockSpec :grails-data-hibernate7-core:cleanTest
:grails-data-hibernate7-core:test --tests MultipleDataSourceConnectionsSpec
--tests Hibernate7RefreshLockSpec :grails-data-mongodb-core:cleanTest
:grails-data-mongodb-core:test --tests MultipleConnectionsSpec
:grails-datamapping-core:codeStyle` on `190ac9c388`: BUILD SUCCESSFUL; 623 + 1
082 + 110 + 109 + 8 tests, 0 failures, 0 errors; result XML written 18:54-18:57
today.
- Codecov line data for `9e0312eb15` (`api.codecov.io` report endpoint): in
`GormStaticApi.groovy` the unhit lines in the scope code are 741 (partial) and
742 (miss).
- Throwaway `ScopeProbeSpec` (DATABASE- and DISCRIMINATOR-mode datastores,
`SystemPropertyTenantResolver`) on `9e0312eb15` and `ScopeProbe2Spec` (`ProbeP`
mapped to `default` and `one`, `ProbeC` unmapped, a `@Transactional` service, a
`@Service(ProbeP)` data service, `TransactionService` of the root and of `one`)
on both `190ac9c388` and `9e0312eb15` produced the numbers quoted above; both
deleted, and the worktree is back on the original branch with no tracked
changes.
- `gh api repos/apache/grails-core/actions/jobs/106810528343`:
`"conclusion":"cancelled"`, `"steps":[]`.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]