Repository navigation
Add the query-tail command to find query-heavy pages - #16631
jdaugherty wants to merge 1 commit into
Conversation
|
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. |
|
Pushed two small commits on top of a3f5590, @jdaugherty — shout if you'd rather have them elsewhere. 97e21ab (build-logic): report a module's 13d66f2 (test): the mkdirs branch of Everything else Codecov listed as missing in Locally on this head: sqltail specs 33 passed (1 skipped: the |
borinquenkid
left a comment
There was a problem hiding this comment.
Once this passes , merge
|
@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. |
|
The JaCoCo change here (97e21ab, with the lazy follow-up 153dc6d) fixes a gap that is not specific to this branch: 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. |
|
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
left a comment
There was a problem hiding this comment.
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).
|
@jamesfredley the two line comments are answered on their threads. The three items from the review body are fixed in 4364005 as well:
The guide section, the |
jamesfredley
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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.
|
Two follow-ups on the syntax split, in ab9131b:
The configuration keys, the command and the capture format are unchanged, and the redactor and inspector specs pass as before. |
|
sql-tail now covers the other two stores GORM runs statements on, in 9f418b2:
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. |
9f418b2 to
b429e37
Compare
|
Two changes to the branch itself, now a single commit, b429e37:
Also in this commit, from a review pass over the Neo4j/MongoDB work: a client-level MongoDB |
b429e37 to
83e2720
Compare
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.
428ae47 to
4156b6b
Compare
4156b6b to
54a6574
Compare
|
Rebased onto 9.0.x at 5175f69. The branch is back to the single feature commit, 54a6574 (same patch as before, unchanged content): the |
|
@jamesfredley re your reviews 5636821537 and 5637351665: the comment-before-bracket leak is fixed on the current head, 54a6574. |
jamesfredley
left a comment
There was a problem hiding this comment.
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.
79330a0 to
5e5feb5
Compare
|
@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 |
jamesfredley
left a comment
There was a problem hiding this comment.
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.
5e5feb5 to
b3a97c2
Compare
|
@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 |
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
left a comment
There was a problem hiding this comment.
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.
b3a97c2 to
b0e9a8c
Compare
|
@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 |
jamesfredley
left a comment
There was a problem hiding this comment.
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.
b0e9a8c to
a50ddf3
Compare
|
@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 |
jamesfredley
left a comment
There was a problem hiding this comment.
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.
a50ddf3 to
49a8b1d
Compare
|
@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 |
✅ All tests passed ✅🏷️ Commit: 49a8b1d Learn more about TestLens at testlens.app/docs. |
jamesfredley
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
/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('$')) { |
There was a problem hiding this comment.
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.
A page that is slow because it runs hundreds of queries is easy to miss in a log:
logSqlprints every statement, but not which page ran it or how many a request added up to. This adds thequery-tailcommand, 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.--statementsranks the statements,--page=book/showshows one page's,--streamprints each statement as it runs with a header per request, and--all --oncereports a whole run, including an integration test run.Design
grails.gorm.queryTail.enabledis 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-tailandbootRun -Pgrails.gorm.queryTail.enabledset it for one run; it can also be kept in config for development.QueryTailStatementInspector; the Neo4j factory wraps the driver in aQueryTailDriver, so every session, transaction and transaction function records its Cypher; the MongoDB factory registers aQueryTailCommandListeneron 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, andQueryTailRequestAttributionreads 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.?and every comment is dropped by a redactor that scans the statement under the lexical rules of its database, each aQueryTailSyntaxverified 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.grails-datamapping-core(org.apache.grails.data.core.querytail, Spring Web compile-only and guarded). The command is a script command in thegrails-datamapping-core-clicompanion, so thegrailsshell 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 isquery-tail/capture.logunder the build directory, tab separated and truncated per run; the reader resets when the application restarts.-Pgrails.gorm.queryTail.*properties to forked application and test JVMs through aGrailsPropertyArgumentProviderthat the startup progress forwarding now shares.Documentation
query-tail, and the--query-tailflag on therun-apppage.build.gradle.grails.gorm.queryTail.enabledandgrails.gorm.queryTail.file.Verification
grails-datamapping-core: fulltestandtestCli(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:GrailsGradlePluginToolchainSpecwith the fork-settings features.codeStyleon the touched modules.