Generated ent/tx.go can lose the stdsql "database/sql" import (undefined: stdsql)
- The issue is present in the latest release.
- I have searched the issues of this repository and believe that this is not a duplicate.
Current Behavior
With the sql/execquery feature enabled, code generation sometimes produces an ent/tx.go that
contains the txDriver.ExecContext / QueryContext methods but not the
stdsql "database/sql" import, so the generated package does not compile:
entc/load: # example.com/myapp/ent
../ent/tx.go:365:82: undefined: stdsql
../ent/tx.go:367:49: undefined: stdsql
../ent/tx.go:377:84: undefined: stdsql
../ent/tx.go:379:51: undefined: stdsqlRegenerating produces a correct file, so in CI it looks like a flake. It is a race in
assets.format(): tx.tmpl never emits that import — goimports does, and goimports runs
concurrently with the writes of the files it needs to read.
1. tx.tmpl does not include additional imports.
The sql/execquery feature is split across two blocks in
entc/gen/template/dialect/sql/feature/execquery.tmpl:
import/additional/stdsql(line 9) — thestdsql "database/sql"importtx/additional/sql/execquery(line 45) — thetxDriver.ExecContext/QueryContextmethods
client.tmpl pulls in additional imports inside its import block:
{{- template "import/additional" $ }} {{/* client.tmpl:69 */}}tx.tmpl does not. Its import block (line 13) is a fixed list (context, sync,
entgo.io/ent/dialect), and its only matchTemplate call is for the body
(tx.tmpl:176, "tx/additional/*"). So the generated tx.go gets the methods but no import.
2. The missing import is supplied by goimports afterwards.
Graph.Gen() writes every asset and then re-processes all of them with imports.Process
(entc/gen/graph.go:1161). For tx.go, goimports resolves the unknown identifier stdsql by
learning the alias from a sibling file in the same package — client.go, which does get the
import from its template.
3. That goimports pass is concurrent and rewrites the very files it reads.
// entc/gen/graph.go:1161
func (a assets) format() error {
var wg errgroup.Group
wg.SetLimit(runtime.GOMAXPROCS(0))
for path, content := range a.files {
path, content := path, content
wg.Go(func() error {
src, err := imports.Process(path, content, nil) // reads sibling files in the same dir
if err != nil {
return fmt.Errorf("format file %s: %w", path, err)
}
if err := os.WriteFile(path, src, 0644); err != nil { // truncates the sibling
return err
}
return nil
})
}
return wg.Wait()
}os.WriteFile truncates before writing, so there is a window in which client.go is empty or
partially written on disk. If tx.go is processed in that window, goimports cannot learn the
stdsql alias — and it returns no error. It writes the file back without the import.
Why only stdsql? fmt is missing from tx.tmpl as well and is also added by goimports,
but it resolves from the standard library by package name and does not depend on sibling files.
stdsql is an alias, so it can only be learned from another file in the same package.
Why it is nondeterministic. For the import to be lost, tx.go's resolution has to read the
directory exactly while client.go is truncated. Whether those two overlap is decided by one
thing: a.files is a map[string][]byte (graph.go:1126), and Go randomizes map iteration order,
so which file goes into which worker slot changes on every run. If client.go is written well
before tx.go starts, or starts well after tx.go has read the directory, nothing happens.
What scales the odds:
| Factor | Where | Effect |
|---|---|---|
| Iteration order | for path, content := range a.files |
whether the two overlap at all |
| Worker count | wg.SetLimit(runtime.GOMAXPROCS(0)) |
how many files are in flight together |
| Length of the window | os.WriteFile(path, src, 0644) |
proportional to file size and filesystem speed |
| The read always happens | imports.Process(path, content, nil) |
it only scans the directory when there is an unresolved identifier — tx.go always has one (stdsql) |
client.go is the largest generated file, so it has both the longest write window and the longest
processing time, which is why it is the one that tends to be in flight when tx.go is processed.
In our project (client.go ≈ 425 KB) a single os.WriteFile takes ~0.14 ms on a local APFS disk,
while the whole generation takes a few seconds — hence roughly one failure in a few hundred CI
runs, on a container filesystem where that write is slower.
Expected Behavior
Code generation should always produce a compilable package. Either the template emits the import it needs, or the formatting pass must not be able to lose it.
Steps to Reproduce
Reproduction repository: https://github.com/yuki2006/ent-tx-stdsql-repro
(ent v0.14.6, sql/execquery, one entity, generated code checked in)
git clone https://github.com/yuki2006/ent-tx-stdsql-repro
cd ent-tx-stdsql-repro
go test -v ./...TestImportIsRecoveredFromSibling— withent/client.gointact,imports.Processrestores the import into atx.gothat is missing it. This is what normally happens during generation.TestImportIsSilentlyLostWhenSiblingIsEmpty— withent/client.gotruncated to zero bytes (what a concurrentos.WriteFiledoes for an instant),imports.Processreturns anilerror and leaves the import out. The result is exactly the brokentx.goabove.TestRaceWithConcurrentSiblingRewrite— reproducesassets.format()'s access pattern: one goroutine rewritesent/client.gowithos.WriteFilein a loop whiletx.gois processed 100 times. On an M-series Mac it lost the import in 65 of 100 runs:--- PASS: TestRaceWithConcurrentSiblingRewrite (34.62s) repro_test.go:105: lost the import in 65/100 runs
The end-to-end flake during real generation is much rarer, for the reasons above: the window is one
os.WriteFile long, and the two files have to be in flight together. In the project where we found
it (~50 entities, client.go ≈ 425 KB, Linux container CI) it hit roughly once in a few hundred
runs. We could not trigger it on demand locally — 323 clean regenerations on a 12-core macOS
machine (8 on the real project, 15 on a 40-entity variant, 300 on the repro above) all succeeded.
Test 3 forces the window open instead, which is why it fails 65% of the time.
Your Environment
| Tech | Version |
|---|---|
| Go | 1.25.5 (repro) / 1.27.0 (original) |
| Ent | 0.14.6 |
| Database | MySQL |
| Driver | https://github.com/go-sql-driver/mysql |
Features enabled in the original project: sql/execquery, schema/snapshot, sql/upsert,
privacy, intercept, sql/lock, sql/modifier. The repro needs only sql/execquery.
Suggested fix
Have tx.tmpl emit its own imports instead of relying on goimports to recover them, the same way
client.tmpl does:
import (
"context"
"sync"
"entgo.io/ent/dialect"
{{- template "import/additional" $ }}
)That removes the dependency on sibling files for this case.
Independently, assets.format() could be made robust: writing to a temporary file and renaming it
(os.Rename is atomic) would remove the window in which a file is observable as empty. It may also
be worth treating an unresolved identifier as an error rather than silently emitting a file that
does not compile.
Possibly related: #4470 reports a different symptom (very slow generation caused by unused imports
that goimports has to strip) with the same underlying cause — templates leaning on
assets.format() to fix up imports instead of emitting exactly what each file needs.
Happy to send a PR for the template change if that direction sounds right.
Source: ent/ent