Skip to content

Add the query-tail command to find query-heavy pages - #16631

Open
jdaugherty wants to merge 1 commit into
9.0.xfrom
feat/sql-tail-command
Open

jdaugherty wants to merge 1 commit into
9.0.xfrom
feat/sql-tail-command

Conversation

@jdaugherty

@jdaugherty jdaugherty commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

A page that is slow because it runs hundreds of queries is easy to miss in a log: logSql prints every statement, but not which page ran it or how many a request added up to. This adds the query-tail command, which shows the pages of a running application ranked by the statements they run per request, and for each page the statement that repeats most within one request, which is what an N+1 looks like. It covers every store GORM runs statements on: SQL through Hibernate 5 and 7, Cypher through Neo4j and commands through MongoDB.

grails run-app --query-tail      # or ./gradlew bootRun -Pgrails.gorm.queryTail.enabled
grails query-tail                # in a second terminal
query-tail build/query-tail/capture.log  312 statements in 14 requests  (refreshes as statements run; Ctrl-C to stop)
PAGE        REQUESTS  AVG/REQ    MAX   TOTAL  MOST REPEATED IN ONE REQUEST
book/show          6     48.0     61     288  44× select ... from author a1_0 where a1_0.id=?
book/index         5      4.2      5      21  5× select ... from book b1_0

--statements ranks the statements, --page=book/show shows one page's, --stream prints each statement as it runs with a header per request, and --all --once reports a whole run, including an integration test run.

Design

  • No cost unless asked. Capture is on only when grails.gorm.queryTail.enabled is set at startup. An application started without it carries no statement inspector, driver wrapper or command listener, no thread and no check per statement. run-app --query-tail and bootRun -Pgrails.gorm.queryTail.enabled set it for one run; it can also be kept in config for development.
  • Three stores, one capture. The Hibernate 5 and 7 connection source factories install a QueryTailStatementInspector; the Neo4j factory wraps the driver in a QueryTailDriver, so every session, transaction and transaction function records its Cypher; the MongoDB factory registers a QueryTailCommandListener on the client, owned by the connection source so that a client stopped for a CRaC checkpoint keeps recording after the restore. Each records on the thread that runs the statement, and QueryTailRequestAttribution reads the controller, action and namespace Grails resolved for the request and gives every statement of one request the same id, so interceptors, the action and GSP rendering count for the same page. Work on other threads is shown under the thread name. A Hibernate inspector the application configured itself keeps running.
  • Nothing of a value is recorded. Every literal becomes ? and every comment is dropped by a redactor that scans the statement under the lexical rules of its database, each a QueryTailSyntax verified against the database itself (H2, PostgreSQL, CockroachDB, Oracle, Db2, HSQLDB, Informix, MySQL/MariaDB, SQL Server/Sybase, GoogleSQL, HANA from its documentation, Cypher against Neo4j 5 and 3.5). A rule that depends on a server setting the capture cannot see is read every way the setting allows, and a statement is recorded only when every reading agrees; otherwise, or when a literal or comment never ends, it is withheld. A MongoDB command is a document, so its values are replaced field by field, and a key below the top of a document is kept only where the mapping knows it as a field, so the keys of a map property never reach the capture.
  • Where it lives. The writer, record format, settings and redaction are in grails-datamapping-core (org.apache.grails.data.core.querytail, Spring Web compile-only and guarded). The command is a script command in the grails-datamapping-core-cli companion, so the grails shell runs it without booting the application; the reader, collapser, summary, renderer and runner sit beside it. Each store's hook sits in its own jar-owned package (org.apache.grails.data.hibernate5.core.querytail, ...hibernate7.core.querytail, ...neo4j.core.querytail, ...mongo.core.querytail). The capture file is query-tail/capture.log under the build directory, tab separated and truncated per run; the reader resets when the application restarts.
  • Gradle. The Grails Gradle plugin hands -Pgrails.gorm.queryTail.* properties to forked application and test JVMs through a GrailsPropertyArgumentProvider that the startup progress forwarding now shares.

Documentation

  • New reference page query-tail, and the --query-tail flag on the run-app page.
  • New guide section Finding Query-Heavy Pages with query-tail under The DataSource, including the redaction rules per database and the shell's limitation with a build directory moved in build.gradle.
  • What's New entry, and configuration metadata for grails.gorm.queryTail.enabled and grails.gorm.queryTail.file.

Verification

  • grails-datamapping-core: full test and testCli (settings, record, attribution, capture, the redactor against every syntax; collapser, summary, reader, renderer, runner, and the script compiled and run the way the shell does).
  • grails-data-hibernate7-core, grails-data-hibernate5-core, grails-data-neo4j-core, grails-data-mongodb-core: full suites, including the inspector, driver and listener specs against a real database (no capture without the flag, values and comments never recorded, every and runtime-added connection sources to one file, capture lifecycle).
  • grails-gradle: GrailsGradlePluginToolchainSpec with the fork-settings features.
  • codeStyle on the touched modules.

@jdaugherty

Copy link
Copy Markdown
Contributor Author

As part of our forge discussion, we need to have a serious discussion about commands that require an app, ones that do not, and then just regular project scripts. It seems there's a gap in what's runnable via gradle - the commands that do not require an app start can't be kicked off currently. This PR is not going to try to tackle that and added sql tail only as a grails command as a result.

@codecov

codecov Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.99425% with 167 lines in your changes missing coverage. Please review.
✅ Project coverage is 64.8168%. Comparing base (5175f69) to head (a50ddf3).
⚠️ Report is 24 commits behind head on 9.0.x.

Files with missing lines Patch % Lines
...data/mongo/core/querytail/MongoMappedFields.groovy 62.3656% 12 Missing and 23 partials ⚠️
...rails/data/core/querytail/StandardSqlSyntax.groovy 84.5454% 2 Missing and 15 partials ⚠️
.../core/querytail/QueryTailRequestAttribution.groovy 90.1316% 2 Missing and 13 partials ⚠️
.../core/querytail/QueryTailStatementInspector.groovy 85.2273% 4 Missing and 9 partials ⚠️
...s/data/core/cli/querytail/QueryTailRenderer.groovy 82.1918% 0 Missing and 13 partials ⚠️
...a/mongo/core/querytail/MongoCommandRedactor.groovy 89.1892% 2 Missing and 10 partials ⚠️
...ils/data/core/cli/querytail/QueryTailRunner.groovy 89.8551% 3 Missing and 4 partials ⚠️
...rails/data/core/cli/querytail/StatementType.groovy 82.9268% 1 Missing and 6 partials ⚠️
.../grails/data/core/querytail/GoogleSqlSyntax.groovy 68.1818% 3 Missing and 4 partials ⚠️
.../grails/data/core/querytail/OracleSqlSyntax.groovy 68.4210% 0 Missing and 6 partials ⚠️
... and 15 more
Additional details and impacted files

Impacted file tree graph

@@                 Coverage Diff                 @@
##                9.0.x     #16631         +/-   ##
===================================================
+ Coverage     45.6019%   64.8168%   +19.2149%     
- Complexity        880      31128      +30248     
===================================================
  Files              89       2402       +2313     
  Lines            5059     118028     +112969     
  Branches          886      21662      +20776     
===================================================
+ Hits             2307      76502      +74195     
- Misses           2440      31749      +29309     
- Partials          312       9777       +9465     
Files with missing lines Coverage Δ
...ions/AbstractHibernateConnectionSourceFactory.java 74.4681% <100.0000%> (ø)
...grails/datastore/mapping/mongo/MongoDatastore.java 82.9601% <100.0000%> (ø)
...ing/mongo/connections/MongoConnectionSource.groovy 100.0000% <100.0000%> (ø)
...go/connections/MongoConnectionSourceFactory.groovy 77.1429% <100.0000%> (ø)
...ails/data/core/querytail/CockroachSqlSyntax.groovy 100.0000% <100.0000%> (ø)
.../grails/data/core/querytail/HsqldbSqlSyntax.groovy 100.0000% <100.0000%> (ø)
...rails/data/core/querytail/InformixSqlSyntax.groovy 100.0000% <100.0000%> (ø)
.../grails/data/core/querytail/QueryTailRecord.groovy 100.0000% <100.0000%> (ø)
...rails/data/core/querytail/QueryTailSettings.groovy 100.0000% <100.0000%> (ø)
.../grails/data/core/querytail/QueryTailSyntax.groovy 100.0000% <100.0000%> (ø)
... and 28 more

... and 2279 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@borinquenkid

Copy link
Copy Markdown
Member

Pushed two small commits on top of a3f5590, @jdaugherty — shout if you'd rather have them elsewhere.

97e21ab (build-logic): report a module's cli source set to JaCoCo. The aggregate fed JaCoCo only each module's main classes and sources, so the eight command classes under grails-datamapping-core/src/cli (~610 lines: collapser, reader, renderer, runner, summary, options, mode, type) were absent from the report and from Codecov — the 85% patch figure covered only the main tier. testCli's exec data was already collected; it just had no classes to match. GrailsJacocoPlugin now adds the cli source set whenever a module has one, with a case in GrailsJacocoPluginSpec. Regenerating the aggregate locally (testCli jacocoAggregateReport) now shows the tier: 299 lines covered, 9 missed (SqlTailRunner 7, SqlStatementCollapser 2). This gap is pre-existing — grails-core's src/cli has the same blind spot — so happy to split it into its own PR if you prefer.

13d66f2 (test): the mkdirs branch of SqlTailCapture.open (:57), the one line your a3f5590 left: the capture creating its parent directory rather than finding it.

Everything else Codecov listed as missing in grails-datamapping-core is covered by your commit; the remaining GrailsGradlePlugin / GrailsPropertyArgumentProvider lines run in TestKit's daemon, where JaCoCo can't see them.

Locally on this head: sqltail specs 33 passed (1 skipped: the /dev/full case, Linux only), codenarcTest clean; GrailsJacocoPluginSpec 7/7.

@borinquenkid
borinquenkid self-requested a review October 20, 2026 16:02

@borinquenkid borinquenkid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once this passes , merge

@jdaugherty

Copy link
Copy Markdown
Contributor Author

@borinquenkid You had introduced non-lazy gradle code and I've now fixed it. This needs back ported to 8.0.x though. I intend to cherry-pick those changes into 8.0.x in a separate PR.

@jdaugherty

Copy link
Copy Markdown
Contributor Author

The JaCoCo change here (97e21ab, with the lazy follow-up 153dc6d) fixes a gap that is not specific to this branch: GrailsJacocoPlugin and its spec are byte-identical on 8.0.x, 8.1.x, 9.0.x and 10.0.x, and the cli source sets of grails-bootstrap, grails-core, grails-openapi, grails-scaffolding and grails-web-url-mappings have been missing from Codecov on all of them since the cli split.

Both commits are cherry-picked onto 8.0.x as #16689 so the fix reaches every branch through the merge-up. Once that lands on 9.0.x, the two commits here meet identical ones and this PR stays a sql-tail change only.

@jdaugherty

Copy link
Copy Markdown
Contributor Author

Once the tests pass, I intend to merge this since it's a stand alone feature. If anyone objects, we can iterate on the change.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not approving. The inspectors do not change the SQL Hibernate executes, and they do not open their own JDBC connections. The capture is not safe to leave on.

record() writes the original SQL, including literals and comments. A native query with password='example-secret' or a key in a comment lands in the capture file and can show up in the CLI. Bound parameters are not the issue. Values already in the SQL text are.

The capture is also never closed when the datastore or application context shuts down. OPEN keeps the writer. A restart in the same JVM reuses the old capture instead of truncating a new file, and a different filename leaks a handle.

Three more CLI and settings problems, not line-commented because I did not re-run them here: a runtime-added connection built from only its own settings drops the application SQL-tail settings (HibernateConnectionSourceFactory around line 326, and the Hibernate 5 twin); the file reader treats truncation as the only restart signal, so a new capture that reaches the old offset before the next poll is skipped (SqlTailFileReader around lines 58-64); request totals are request-wide while page labels can change for the same request id, and interleaved requests reset the stream count (SqlTailSummary around 173 and SqlTailRenderer around 148).

@jdaugherty

Copy link
Copy Markdown
Contributor Author

@jamesfredley the two line comments are answered on their threads. The three items from the review body are fixed in 4364005 as well:

  • A runtime-added connection dropped the application's sql-tail settings. HibernateConnectionSourceFactory (H7) and AbstractHibernateConnectionSourceFactory (H5) now read them with SqlTailSettings.from(configuration, fallback), where the fallback is the sql-tail settings of the connection source it is built after. A connection added through addConnectionSource captures as the application does unless its own configuration says otherwise. Covered by a data source added at runtime records as the application does in both inspector specs, and by SqlTailSettingsSpec.
  • Truncation was the only restart signal. The first line of the capture now names the run (# sql-tail capture <uuid>), and SqlTailFileReader starts over when that line names a different run, as well as when the file shrinks. A file caught between the truncation and its header being written is picked up without a second restart. SqlTailFileReaderSpec covers a new run that grew past the old offset before the next poll; SqlTailRunnerSpec covers it end to end in the stream.
  • Request totals versus page labels, and interleaved stream counts. SqlTailSummary now keeps request statistics per page, so a page's MAX and most repeated statement count only what a request ran on that page; a forward, or a filter query before the controller is chosen, counts the request on each page with its own statements. SqlTailRenderer keeps a running count per request instead of dropping it when another request interleaves, heads a returning request as (continued), keeps the 1000 most recent requests, and forgets them on a restart. Covered in SqlTailSummarySpec, SqlTailRendererSpec and SqlTailRunnerSpec.

The guide section, the sql-tail reference page and What's New now describe the redaction and the capture lifecycle.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The capture now closes when the session factory closes, and that close is idempotent. That half of the earlier comment is fixed. Not approving. A double-quoted SQL Server string can still land in the file.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The SQL Server double-quoted literal is fixed. TRANSACT_SQL replaces that span with ?, and the capture file test asserts example-secret is absent. That thread is resolved. The close-on-shutdown path is unchanged. Not approving. Other literal forms still write part of a value, and a restart can kill the reader.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two earlier comments are fixed. Spanner triple quotes are redacted as GoogleSQL, and a short read after truncation is a retry instead of an exit. Those threads are resolved. Not approving. Other literals can still reach the file, and a large capture can crash the reader.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two earlier comments are fixed. A number after # or @ is withheld outside Transact-SQL, and $987654321 is redacted there. The reader no longer allocates the whole unread range. Those threads are resolved. Not approving. A MySQL comment can still keep a secret.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The MySQL control-character comment is fixed. select 1 --<U+0001> api-key=example-secret is redacted. That thread is resolved. Not approving. An H2 array with a space before [ still writes the secret.

@jdaugherty

Copy link
Copy Markdown
Contributor Author

Two follow-ups on the syntax split, in ab9131b:

  • Every file this branch adds is now Groovy under @CompileStatic. The redactor, the syntax classes and the two statement inspectors were Java.
  • The sql-tail code moved to a package root each jar owns, nested under its project, so no package of one jar nests inside another's and modules can be introduced later: org.apache.grails.data.core.sqltail in grails-datamapping-core, org.apache.grails.data.core.cli.sqltail in its CLI artifact, and org.apache.grails.data.hibernate5.core.sqltail / org.apache.grails.data.hibernate7.core.sqltail in the Hibernate cores, following the org.apache.grails.data.hibernate*.core packages already there.

The configuration keys, the command and the capture format are unchanged, and the redactor and inspector specs pass as before.

@jdaugherty

Copy link
Copy Markdown
Contributor Author

sql-tail now covers the other two stores GORM runs statements on, in 9f418b2:

  • Neo4j. A SqlTailDriver wraps the driver each connection source builds, so every session, transaction and transaction function records its Cypher before it runs; parameters are never recorded. Cypher gets a syntax of its own, SqlTailSyntax.CYPHER, verified against Neo4j 5 in a container and the embedded 3.5 the tests run on. The two versions differ on two points and the syntax takes neither side: whether a // comment ends at a carriage return is read both ways, and a backslash outside a string or comment, which Neo4j 5 decodes into a quote before parsing, withholds the statement.
  • MongoDB. A SqlTailCommandListener on each client records a command as its name, namespace and entries with every value replaced, e.g. find shop.book {"filter": {"title": ?}, "limit": ?}. Since a command is a document, the only place data can hide is a key, so below the top level a key is kept only where the mapping knows it as a field, resolved through embedded entities; the keys of a map property, a dynamic attribute or an unmapped collection are recorded as ? too. The connection source owns the capture, so a client stopped for a CRaC checkpoint keeps recording after the restore.
  • Command. Cypher is typed by the clauses that write in it, Mongo commands by their name, and both are shown whole. Guide and reference updated.

Each store has an integration spec against a real server (embedded Neo4j, a mongod) covering values, multiple and runtime-added connection sources, and capture lifecycle, plus a table-driven spec for the Mongo redactor; the Cypher rows joined the redactor spec.

@jdaugherty
jdaugherty force-pushed the feat/sql-tail-command branch from 9f418b2 to b429e37 Compare October 23, 2026 20:37
@jdaugherty jdaugherty changed the title Add the sql-tail command to find query-heavy pages Add the query-tail command to find query-heavy pages Oct 23, 2026
@jdaugherty

Copy link
Copy Markdown
Contributor Author

Two changes to the branch itself, now a single commit, b429e37:

  • Renamed to query-tail. With Cypher and MongoDB commands captured alongside SQL, sql-tail no longer said what it does. The command is grails query-tail, the flag run-app --query-tail, the settings grails.gorm.queryTail.enabled and .file, the capture file build/query-tail/capture.log, the packages org.apache.grails.data.*.querytail, and the classes QueryTail*; the CLI's SqlStatementType and SqlStatementCollapser became StatementType and StatementCollapser for the same reason. The guide section, the reference page, the What's New entry and the configuration metadata follow. The PR title and description are updated.
  • Squashed. The fourteen commits are one, so the earlier line-comment anchors point at history that is no longer on the branch; the threads themselves are all resolved.

Also in this commit, from a review pass over the Neo4j/MongoDB work: a client-level MongoDB bulkWrite now has the keys of its document and updateMods entries resolved against the mapping like insert and update do, so a map property's keys cannot leak through it; a capture path that cannot be resolved raises the documented UncheckedIOException rather than a sneaky IOException through the Java Neo4j factory; the run-app flag, the command description and the configuration metadata describe all three stores; and the connection-source and factory specs of the MongoDB and Hibernate modules gained the query-tail settings and listener lifecycle cases.

@jdaugherty
jdaugherty force-pushed the feat/sql-tail-command branch from b429e37 to 83e2720 Compare October 23, 2026 20:57
jdaugherty added a commit that referenced this pull request Oct 24, 2026
AstUtils.isDomainClass(ClassNode) was @memoized. Groovy's default memo
is an unbounded cache keyed by argument equality, and ClassNode equality
is the class name, so every node ever asked about was pinned for the
life of the JVM, and with it the node's module, the compile unit and the
class loader of its compilation. In a forked Groovy compiler that Gradle
reuses for a project's main and test compiles, that is the whole AST of
the previous compile: measured after the grails-datamapping-core test
compile, the worker still held 194,420 ClassNodes and 154 ModuleNodes,
222 MB after a full collection, when the next compile started. The
MongoDB core test compile of #16631 then ran out of its 2 GB on CI.

The verdict now lives as node metadata on the node's redirect, so it is
collected with the node, and two same-named nodes from different
compilations, such as test fixtures compiled from strings, each get
their own answer. #16653 had added isDomainClassUncached to sidestep
that collision for the id and version placement; the uncached check now
backs the cached one. The memo dates from GORM itself (ee14a96, 2015)
and came in with the GORM integration (a58aa14); it is the same on
every branch from 7.0.x up.
@jdaugherty
jdaugherty force-pushed the feat/sql-tail-command branch from 428ae47 to 4156b6b Compare October 24, 2026 07:46
@jdaugherty
jdaugherty force-pushed the feat/sql-tail-command branch from 4156b6b to 54a6574 Compare October 24, 2026 07:58
@jdaugherty

Copy link
Copy Markdown
Contributor Author

Rebased onto 9.0.x at 5175f69. The branch is back to the single feature commit, 54a6574 (same patch as before, unchanged content): the isDomainClass verdict fix and the removal of the MongoDB modules' 768 MB compiler cap now reach 9.0.x through #16747's merge-up, so their copies here are dropped. Verified on the new base: datastore-core compile, the query-tail specs (376), the datamapping-core cli tests, style check, and the MongoDB/Neo4j/Hibernate 7 core test compiles.

@jdaugherty

Copy link
Copy Markdown
Contributor Author

@jamesfredley re your reviews 5636821537 and 5637351665: the comment-before-bracket leak is fixed on the current head, 54a6574. H2SqlSyntax now treats the two characters that close a block comment like whitespace before a [ (afterGap replaces afterWhitespace in both the readings decision and endOfQuotedName), so ARRAY/**/['example-secret'] and ARRAY /* c */['example-secret'] get the name reading and the array-literal reading and are withheld. Covered in QueryTailRedactorSpec (withheld table and never-survives list, including ARRAY -- c\n[...]) and through the capture API in QueryTailCaptureSpec, where those forms are withheld while [order] from [user] is still recorded; the guide names the comment case. Details are in the replies on your two threads (#16631 (comment) and #16631 (comment)), which I resolved; happy to reopen them if you prefer to close them yourself.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment-adjacent H2 array is fixed. ARRAY/**/['example-secret'] is withheld, and the capture test asserts the secret is absent. That thread is resolved. Not approving. A percent-encoded path can still keep a value, and --stream --page ignores the page.

@jdaugherty
jdaugherty force-pushed the feat/sql-tail-command branch from 79330a0 to 5e5feb5 Compare October 24, 2026 12:05
@jdaugherty

jdaugherty commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor Author

@jamesfredley re review 5637980816: both items are fixed on head 5e5feb5. The request path is now matched percent-decoded as well as as-sent, so /search/secret%40example.com with q=secret@example.com is recorded as GET /search/?, asserted through the capture API in QueryTailCaptureSpec (neither the encoded nor the decoded form reaches the file) and in the attribution spec, including values whose slashes the client encoded. --stream --page=<page> now applies the page match before a streamed record is rendered, with the same rule as the summary; QueryTailRunnerSpec covers controller/action and a bare controller name. Details are in the replies on your two threads, which I have left open for you to resolve.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two earlier comments are fixed. A percent-encoded path is masked, and --stream --page filters before it prints. Those threads are resolved. Not approving. A MongoDB map key and a document comment can still keep a value, and two JVMs can write a record before the run header.

@jdaugherty
jdaugherty force-pushed the feat/sql-tail-command branch from 5e5feb5 to b3a97c2 Compare October 24, 2026 14:12
@jdaugherty

Copy link
Copy Markdown
Contributor Author

@jamesfredley re review 5638523900: both items are fixed on head b3a97c2. A numeric key is kept only where the path before it is a field the mapping knows as an array; in a map property, under an unknown key or at the top of a document it is recorded as ?, asserted in MongoCommandRedactorSpec and through the capture API in QueryTailCommandListenerSpec against a real mongod (the numeric key of an attrs map reaches neither the insert nor the find in the file). The run header is now written while the starter still holds the STARTING lock, so a joiner cannot append before it. Details are in the replies on your two threads, left open for you.

codeconsole pushed a commit to codeconsole/grails-core that referenced this pull request Oct 24, 2026
AstUtils.isDomainClass(ClassNode) was @memoized. Groovy's default memo
is an unbounded cache keyed by argument equality, and ClassNode equality
is the class name, so every node ever asked about was pinned for the
life of the JVM, and with it the node's module, the compile unit and the
class loader of its compilation. In a forked Groovy compiler that Gradle
reuses for a project's main and test compiles, that is the whole AST of
the previous compile: measured after the grails-datamapping-core test
compile, the worker still held 194,420 ClassNodes and 154 ModuleNodes,
222 MB after a full collection, when the next compile started. The
MongoDB core test compile of apache#16631 then ran out of its 2 GB on CI.

The verdict now lives as node metadata on the node's redirect, so it is
collected with the node, and two same-named nodes from different
compilations, such as test fixtures compiled from strings, each get
their own answer. apache#16653 had added isDomainClassUncached to sidestep
that collision for the id and version placement; the uncached check now
backs the cached one. The memo dates from GORM itself (ee14a96, 2015)
and came in with the GORM integration (a58aa14); it is the same on
every branch from 7.0.x up.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The run header is now written before another JVM can append, and a numeric key on a map property is masked. Those two are resolved. Not approving. A numeric key inside a list of maps is still written, and a path seen before URL mapping is written whole.

@jdaugherty
jdaugherty force-pushed the feat/sql-tail-command branch from b3a97c2 to b0e9a8c Compare October 24, 2026 17:32
@jdaugherty

Copy link
Copy Markdown
Contributor Author

@jamesfredley re review 5639132816: both items are fixed on head b0e9a8c. The redactor scope now advances into array elements (and into the document of an operator on an array), and a numeric key is kept only where the mapping knows an array at that depth, so a numeric key in a List<Map> element, in attributes.0.<key> or under $elemMatch records ?; asserted in MongoCommandRedactorSpec and through the capture API against a real mongod in QueryTailCommandListenerSpec. A request line written before the URL mapping has run records only the shape of the path (GET /?/?/?), since nothing tells a literal segment from a token yet; later statements of the same request get the mapped line. Details are in the replies on your two threads, left open for you.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A numeric key inside a list of maps is masked, and a path with no parameters is withheld. Those two are resolved. Not approving. A client-supplied controller parameter makes the path look mapped, and a mapped value glued to static text is still written.

@jdaugherty
jdaugherty force-pushed the feat/sql-tail-command branch from b0e9a8c to a50ddf3 Compare October 24, 2026 20:54
@jdaugherty

Copy link
Copy Markdown
Contributor Author

@jamesfredley re review 5639950802: both items are fixed on head a50ddf3. Only the controller attribute the framework sets counts as the URL mapping having run, never a parameter, so /password/reset/k9fQ2x?controller=x before mapping records GET /?/?/? (covered in the attribution spec). A mapped value glued to static text is cut out of its segment, so /reset/k9fQ2x_confirm with token=k9fQ2x records GET /reset/?_confirm; values of three or more characters are cut wherever they appear, shorter ones only where no letter or digit continues them. Details are in the replies on your two threads, which I resolved since both are fixed; reopen either if you want to discuss it further.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The two earlier comments are fixed. A client controller parameter no longer counts as mapping, and /reset/k9fQ2x_confirm is masked. Those threads are resolved. Not approving. A mixed encoded segment still keeps the token.

A page that is slow because it runs hundreds of queries is easy to miss
in a log: logSql prints every statement, but not which page ran it or
how many a request added up to. query-tail shows the pages of a running
application ranked by the statements they run per request, and for each
page the statement that repeats most within one request, which is what
an N+1 looks like. It covers every store GORM runs statements on: SQL
through Hibernate 5 and 7, Cypher through Neo4j and commands through
MongoDB.

Capture is on only when grails.gorm.queryTail.enabled is set at startup
(grails run-app --query-tail, ./gradlew bootRun
-Pgrails.gorm.queryTail.enabled, or configuration); an application
started without it carries nothing that records. The Hibernate
connection source factories install a statement inspector, the Neo4j
factory wraps the driver, and the MongoDB factory registers a command
listener, each recording on the thread that runs the statement, so a
QueryTailRequestAttribution can attribute it to the controller and
action Grails resolved for the request. The writer, record format,
settings and redaction live in grails-datamapping-core, in
org.apache.grails.data.core.querytail; the command is a script command
in the grails-datamapping-core-cli companion, so the shell runs it
without booting the application. The Grails Gradle plugin hands the
-Pgrails.gorm.queryTail.* properties to forked application and test
JVMs.

Statements are captured without their values. Every literal is recorded
as ? and every comment is dropped by a redactor that scans the statement
under the lexical rules of its database, each a QueryTailSyntax verified
against the database itself: H2, PostgreSQL, CockroachDB, Oracle, Db2,
HSQLDB, Informix, MySQL and MariaDB, SQL Server and Sybase, GoogleSQL,
SAP HANA from its documentation, and Cypher against Neo4j 5 and 3.5. A
rule that depends on a server setting the capture cannot see is read
every way the setting allows, and a statement is recorded only when
every reading finds the same values; one that fails that, or whose
literal or comment never ends, is withheld. A MongoDB command is a
document, so its values are replaced field by field, and a key below
the top of a document is kept only where the mapping knows it as a
field, so that the keys of a map property never reach the capture.
The request a statement is attributed to is recorded as its method
and path with the values in the path replaced, those of its mapped
parameters and any number, UUID or hexadecimal token among them.

The capture file is written a line at a time through a channel, and a
lock file beside it tells the JVMs that run at the same time with the
flag, such as the parallel forks of a test task, which of them starts
the run and which join it, with their requests numbered apart.

The guide gains a section under The DataSource, the command reference a
query-tail page and the --query-tail flag on run-app, and the
configuration metadata the two settings.
@jdaugherty
jdaugherty force-pushed the feat/sql-tail-command branch from a50ddf3 to 49a8b1d Compare October 25, 2026 00:14
@jdaugherty

Copy link
Copy Markdown
Contributor Author

@jamesfredley re review 5640715445: fixed on head 49a8b1d. A segment a mapped value is glued into is now withheld whole whenever either its sent or its percent-decoded form holds the value, so /reset/%6B9fQ2x_confirm?q=confirm with token=k9fQ2x records GET /reset/?; covered through attribute() in the attribution spec, with and without the q parameter. Details are in the reply on your thread, which I resolved since it is fixed; reopen it if you want to discuss further.

@testlens-app

testlens-app Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

✅ All tests passed ✅

🏷️ Commit: 49a8b1d
▶️ Tests: 30485 executed
⚪️ Checks: 97/97 completed


Learn more about TestLens at testlens.app/docs.

@jamesfredley jamesfredley left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mixed encoded segment is fixed. /reset/%6B9fQ2x_confirm is recorded as GET /reset/?. That thread is resolved. Not approving. A rewritten session id and a dollar-prefixed MongoDB map key can still be written.

if (!(attributes instanceof ServletRequestAttributes)) {
return null
}
def request = ((ServletRequestAttributes) attributes).request

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/book/index;jsessionid=abc123secretXYZ is recorded unchanged. The session id is not a parameter and does not match the numeric, UUID, or hex patterns.

Mask a semicolon path parameter, especially jsessionid, before the request is written. Please assert that value does not reach the file.

boolean fieldKeyed = FIELD_KEYED.contains(key)
return new Scope(collection, fields, Collections.<String>emptyList(), !fieldKeyed, quote(key), 0)
}
if (key.startsWith('$')) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MongoDB 5+ allows a stored field name to start with $. Inserting attrs: ['$example-secret': 'v'] keeps $example-secret because every dollar-prefixed key is treated as an operator, including inside a map.

Keep operator names only in an operator context. Mask a dollar-prefixed key in stored data.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants