Skip to content

schema/field: the loader lists a GoType's methods, and a program that makes fields keeps only the methods it calls - #20

Merged
lesomnus merged 1 commit into
masterfrom
methods-at-load
Oct 24, 2026
Merged

lesomnus merged 1 commit into
masterfrom
methods-at-load

Conversation

@lesomnus

@lesomnus lesomnus commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

For lesomnus/payday's sandbox, which a browser downloads: 24 MB of its 63 MB was the linker keeping methods nothing calls, and this was why.

What was wrong

  • A descriptor listed its type's methods as it was made. goType, which every field.Uuid, field.Json, field.Other and .GoType goes through, called methods, which is reflect.Type.Method(i) for each method -- for codegen, which reads RType.Methods to know what a GoType implements.
  • A running program makes descriptors too. A generated client whose schema has a default, a validator or a value scanner calls the schema's Fields at init (runtime.go), so it builds every field of that schema.
  • The linker cannot prune methods in such a program. A function that calls reflect.Type.Method, or MethodByName with a name the compiler cannot see, is marked for it (<ReflectMethod> in -dumpdep). In a program that can reach one, it has to assume any exported method of any type converted to an interface may be called by name, so it keeps all of them, in every package, used or not. Nothing fails.
  • RType.Implements was a second one. Its name and signature are reflect.Type's own Implements, which a program calls through the interface (encoding/json does), and the linker keeps a method of that name and type on every type converted to an interface. An RType is one, behind each field's builder, and the method's loop is the same call by index. Taking out the listing alone left the binary as big: the linker reached this next.

The chain the linker took, in payday's app:

go:main.inittasks
…/internal/ent..inittask
…/internal/ent.init.0                       (runtime.go)
…/internal/ent/schema.Access.Fields
github.com/protobuf-orm/ent/schema/field.Json
github.com/protobuf-orm/ent/schema/field.(*Descriptor).goType
github.com/protobuf-orm/ent/schema/field.methods <ReflectMethod>

Every payday app has paid it since payday's Audit gave its domain column a default (protoc-gen-orm-ent#8 turned a declared default into .Default(v), and payday took it in payday#87): that put the schema into every app's runtime.go, including apps with no default of their own.

What changed

  • The loader lists the methods. RType.LoadMethods does what methods did, and entc/load's NewField calls it on every field before the schema is encoded. A descriptor records the type alone, and Methods is nil until it is loaded.
  • RType.Implements is implements, unexported. It asks reflect of a type a descriptor holds, and the recorded methods of one decoded from a loaded schema, which is what entc has. TypeInfo's ValueScanner, Validator, Valuer and Stringer call it as before.
  • The enum builder's Stringer check asks reflect of the type itself. It went through TypeInfo.Stringer, which reaches the loop; it would also have answered from methods that are no longer recorded at that point.
  • UPSTREAM.md gets a Behaviour row: an upstream change that lists methods in goType again, or checks a descriptor through TypeInfo's predicates, undoes this.

What is generated does not change. The generate gate regenerates every example and integration schema, including the snapshots that carry RType.Methods (entc/integration/privacy/ent/internal/schema.go and others), and nothing moves. So an app regenerates nothing; it takes the fix by taking the module.

Nothing in the protobuf-orm org calls RType.Implements (protoc-gen-orm-ent, payday, roster and rove searched).

Tests

  • TestMakingFieldsLetsTheLinkerPruneMethods builds schema/field/testdata/linked, which makes a schema's fields as runtime.go does -- a uuid with a default, JSON, Other, a GoType with a value scanner, an enum GoType, a defaulted uint32 -- and asks reflect.Type.Implements through the interface. It reads the linker's dump (-dumpdep). No marked function may be reached, and a failure names the chain.
    • With each of the three put back, it fails: the listing in goType, an exported Implements, the enum check through TypeInfo.Stringer. Each names its own chain.
    • The same program built with -tags load, which does what the loader does, must be marked at (*RType).LoadMethods. So a dump that could not show the mark would not pass.
  • TestRType_Implements asks a descriptor's RType before it is loaded (reflect), and after it is loaded and decoded through JSON (the recorded methods). A type with no methods loads as an empty set, as the snapshots have it ({}).
  • Run here, with green results:
    • go test -race ./... in cmd, dialect, schema, entc/load, entc/gen and examples.
    • dialect/sql/schema/integration on SQLite.
    • The generate gate.
    • .github/lint.sh over every tracked file.
    • entc/integration with -race -count=2 on all six engines of compose.test.yaml, on this branch and on master. MySQL 8.0 ran on its own once it accepted connections: 8.0.46 took minutes to start here.

CI has not run

GitHub has created no workflow run on this repository since 2026-10-21. These all made no run:

The workflow is active. Actions is enabled for the repository and allows every action. githubstatus.com shows no incident. lesomnus/payday's runs started as usual over the same hours. The checks above are what the lint, unit, generate and integration jobs run, run by hand.

Measured

GOOS=js GOARCH=wasm go build -tags grpcnotrace ./wasm, with this branch replaced in:

before after
payday's app (internal/apptest, payday 1b4425d) 62,994,032 B 39,013,503 B

With runtime.go's init deleted outright it is 38,628,342 B, so what is left of the schema at init is 0.4 MB.

What follows

A payday PR that takes this commit, with a test that links payday's sandbox and refuses any marked function in it. Apps get it from payday's go.mod.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk

… makes fields keeps only the methods it calls

A descriptor listed its Go type's methods as it was made (goType, with
reflect.Type.Method for each of them), for codegen to read: entc knows
what a GoType implements from RType.Methods. A running program makes
descriptors too. A generated client whose schema has a default, a
validator or a value scanner calls the schema's Fields at init
(runtime.go), so every such program reached reflect.Type.Method with an
index the compiler cannot see -- and in a program that can reach one,
the linker has to assume that any exported method of any type converted
to an interface may be called by name. It kept all of them, used or
not, in the whole program. Nothing failed. In lesomnus/payday's app
compiled to WebAssembly, the module a browser downloads, that was 24 MB
of 63; an app whose own schema has no default has paid it since
payday's Audit gave its `domain` column one.

The loader lists them now (RType.LoadMethods, from entc/load's
NewField), before it encodes the schema, and a descriptor records the
type alone. What is generated is the same: the generate gate
regenerates every example and integration schema, snapshots included,
and nothing moves.

RType.Implements could not stay as it was either. Its name and
signature are reflect.Type's own Implements, which a program calls
through the interface (encoding/json does), and the linker keeps a
method of that name and type on any type converted to an interface --
which an RType is, behind each field's builder -- and its loop was the
same call by index. It is `implements` now: it asks reflect of a type a
descriptor holds, and the recorded methods of one decoded from a loaded
schema, which is what entc has. The enum builder's check that a GoType
is a string or a Stringer asked TypeInfo.Stringer, which reaches that
loop; it asks reflect of the type itself.

TestMakingFieldsLetsTheLinkerPruneMethods builds a program that makes a
schema's fields as runtime.go does, and reads the linker's dump of it
(-dumpdep): no function marked for calling a method by index may be
reached. It fails with each of the three put back -- the listing in
goType, an exported Implements, the enum check through TypeInfo -- and
names the chain the linker took. The same program built with what the
loader does must be marked, so a dump that could not show it would not
pass. TestRType_Implements asks a descriptor's RType before it is
loaded, and after it is loaded and decoded as entc has it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk
@lesomnus lesomnus closed this Oct 23, 2026
@lesomnus lesomnus reopened this Oct 23, 2026
lesomnus added a commit to lesomnus/payday that referenced this pull request Oct 23, 2026
…it calls, and a test links it to say so

payday's own sandbox was 63 MB, and 24 MB of it was methods nothing
calls. The generated client's runtime.go makes the ent schema's fields
at init, for their defaults and value scanners, and every field it made
listed its Go type's methods with reflect.Type.Method, by an index the
compiler cannot see. In a program that can reach such a call the linker
has to assume that any exported method of any type converted to an
interface may be called by name, so it keeps all of them, in every
package. The page worked, and no list of packages showed it: the call
was in one the sandbox has to link.

An app with no default of its own has paid it since #87.
protoc-gen-orm-ent began writing a declared default as the column's
(`.Default(v)`, protobuf-orm/protoc-gen-orm-ent#8), and payday's Audit
declares one on `domain`, so since then every app's runtime.go reads the
schema at init -- for Access's `domain` as well, since #112. khala's
sandbox measured 25.8 MB on payday 6bb6d9c and 46.9 MB on c0f5e7d. This
app paid it longer, for its Holder's value scanner.

protobuf-orm/ent#20 (4d86cf3) lists the methods in its loader, which
only codegen runs, and takes RType.Implements out of the exported API:
it has reflect.Type's name and signature, which the linker keeps on any
type converted to an interface, and the same call in its loop. What ent
generates is the same but for its stamp of which ent made it
(`internal/ent/runtime/runtime.go`), the one file apptest's regeneration
moves; an app takes the fix with payday's go.mod, and regenerates for
the stamp. The bump brings protobuf-orm/ent#17 too -- one process
applies a migration directory at a time, and an edited file it ran is
refused -- which payday does not call.

TestTheSandboxLetsTheLinkerPruneMethods links this app's sandbox as it
ships, under GOOS=js with grpcnotrace, with the linker's dump
(-dumpdep), and fails on any function in it marked for calling a method
by index, with the chain the linker took to it. On the ent before this
it fails at field.methods, reached from runtime.go's init through
schema.Access.Fields, field.Json and goType.

Measured, `GOOS=js GOARCH=wasm go build -tags grpcnotrace ./wasm`:

- this app: 62,994,314 B before, 39,013,431 B after (gzip -9: 10.99 MB,
  7.61 MB). Without runtime.go's init at all it is 38,628,342 B.
- roster c4ae61c, with this payday replaced in: 89,958,712 B before,
  56,631,026 B after (gzip -9: 15.47 MB, 11.03 MB).

docs: client.md says what a method called by index costs a sandbox,
runtime.md's table of what enforces what has the test, and migrating.md
says an app regenerates and what it gets back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk
lesomnus added a commit to lesomnus/payday that referenced this pull request Oct 23, 2026
…it calls, and a test links it to say so

payday's own sandbox was 63 MB, and 24 MB of it was methods nothing
calls. The generated client's runtime.go makes the ent schema's fields
at init, for their defaults and value scanners, and every field it made
listed its Go type's methods with reflect.Type.Method, by an index the
compiler cannot see. In a program that can reach such a call the linker
has to assume that any exported method of any type converted to an
interface may be called by name, so it keeps all of them, in every
package. The page worked, and no list of packages showed it: the call
was in one the sandbox has to link.

An app with no default of its own has paid it since #87.
protoc-gen-orm-ent began writing a declared default as the column's
(`.Default(v)`, protobuf-orm/protoc-gen-orm-ent#8), and payday's Audit
declares one on `domain`, so since then every app's runtime.go reads the
schema at init -- for Access's `domain` as well, since #112. khala's
sandbox measured 25.8 MB on payday 6bb6d9c and 46.9 MB on c0f5e7d. This
app paid it longer, for its Holder's value scanner.

protobuf-orm/ent#20 (4d86cf3) lists the methods in its loader, which
only codegen runs, and takes RType.Implements out of the exported API:
it has reflect.Type's name and signature, which the linker keeps on any
type converted to an interface, and the same call in its loop. What ent
generates is the same but for its stamp of which ent made it
(`internal/ent/runtime/runtime.go`), the one file apptest's regeneration
moves; an app takes the fix with payday's go.mod, and regenerates for
the stamp. The bump brings protobuf-orm/ent#17 too -- one process
applies a migration directory at a time, and an edited file it ran is
refused -- which payday does not call.

TestTheSandboxLetsTheLinkerPruneMethods links this app's sandbox as it
ships, under GOOS=js with grpcnotrace, with the linker's dump
(-dumpdep), and fails on any function in it marked for calling a method
by index, with the chain the linker took to it. On the ent before this
it fails at field.methods, reached from runtime.go's init through
schema.Access.Fields, field.Json and goType.

Measured, `GOOS=js GOARCH=wasm go build -tags grpcnotrace ./wasm`:

- this app: 62,994,314 B before, 39,013,431 B after (gzip -9: 10.99 MB,
  7.61 MB). Without runtime.go's init at all it is 38,628,342 B.
- roster c4ae61c, with this payday replaced in: 89,958,712 B before,
  56,631,026 B after (gzip -9: 15.47 MB, 11.03 MB).

docs: client.md says what a method called by index costs a sandbox,
runtime.md's table of what enforces what has the test, and migrating.md
says an app regenerates and what it gets back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk
@lesomnus
lesomnus force-pushed the methods-at-load branch 2 times, most recently from 5742557 to 4d86cf3 Compare October 23, 2026 23:20
@lesomnus lesomnus closed this Oct 24, 2026
@lesomnus lesomnus reopened this Oct 24, 2026
@lesomnus
lesomnus merged commit 2248bed into master Oct 24, 2026
4 checks passed
@lesomnus
lesomnus deleted the methods-at-load branch October 24, 2026 09:35
lesomnus added a commit to lesomnus/payday that referenced this pull request Oct 24, 2026
The same change as 4d86cf3, which this pinned while protobuf-orm/ent#20
was open: the merge commit is master's, and runtime.go carries its stamp.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxtnpRW533bdErnW8QPhsk
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant