Skip to content

feat(protogen): support per-sheet union split via WorksheetOptions - #444

Open
hazy-wu wants to merge 3 commits into
tableauio:masterfrom
hazy-wu:feat/per-sheet-union-split
Open

feat(protogen): support per-sheet union split via WorksheetOptions#444
hazy-wu wants to merge 3 commits into
tableauio:masterfrom
hazy-wu:feat/per-sheet-union-split

Conversation

@hazy-wu

@hazy-wu hazy-wu commented Aug 4, 2026

Copy link
Copy Markdown

Summary

Add a per-sheet knob to split an oversized MODE_UNION_TYPE sheet into
auxiliary shard proto files, instead of always inlining every sub-message into
the single union message (which can produce a multi-megabyte generated file for
huge unions).

The switch is controlled through the existing @TABLEAU metasheet columns
UnionSplitThreshold and UnionSplitShardSize, mapped to WorksheetOptions
same wiring path as Mode, FieldPresence, OrderedMap, etc.

Motivation

A huge union sheet (hundreds of sub-messages) emits one very large generated
file that dominates compile time for downstream consumers (e.g. a single
~5.7 MB .pb.cc on the C++ side). Splitting only that sheet cuts the largest
translation unit by ~80% and lets the build parallelize the shards.

However splitting every union is undesirable: small/mid unions read much
better as nested messages. A per-sheet knob is the right granularity, and it is
backward compatible — sheets that don't set the columns keep emitting exactly
the same nested output.

Design

  • WorksheetOptions gains two fields:
    • union_split_threshold = 24 — split when the sub-message count exceeds it.
    • union_split_shard_size = 25 — how many sub-messages go into each shard file.
    • Both default to 0, meaning disabled (byte-for-byte identical to today).
  • Metasheet gains matching metasheet columns
    UnionSplitThreshold (field 25) / UnionSplitShardSize (field 26).
  • internal/importer/book/sheet.go copies the two fields from Meta into
    WorksheetOptions (one line per field, same shape as every other metasheet option).
  • internal/protogen/exporter.go reads
    ws.GetOptions().GetUnionSplitThreshold() / GetUnionSplitShardSize() at the
    exportUnion site; when triggered, sub-messages are extracted as top-level
    messages <Union>T_<SubType> into per-shard proto files and the main proto
    imports them.
  • Extracted sub-messages are renamed to <Union>T_<SubType>. The T_ separator
    is required: protoc-gen-go names each oneof wrapper <Parent>_<CamelField>
    and only disambiguates intra-file, so using _ would cause a redeclared
    error across shard files. Business code that only depends on the Type enum
    and generated accessors is unaffected.

Behavior

Configuration Result
threshold = 0 (default) Unchanged nested union — all existing golden/functest fixtures pass as-is
threshold > 0, sub-msgs ≤ threshold Unchanged nested union
threshold > 0, sub-msgs > threshold Split into shard files, main proto imports them

Tests

  • Test_sheetExporter_exportUnion:
    • case export-union (threshold = 0) — verifies the default nested behavior.
    • case export-union-split — verifies the new shard-emitter path (golden diff).
  • Existing functest golden dirs (test/functest/testdata/.../union,
    .../metasheet) are unaffected because they don't set the threshold.

Notes for reviewers

  • No new goroutines / no data-race surface; go test -race clean.
  • No new dependencies; go.mod stays on go 1.21.
  • The T_ separator is the only non-obvious choice — rationale is in the
    unionSplitTypeSep doc comment in exporter.go.

Some worksheets define very large unions that make protoc-gen-cpp emit
a single very large .pb.cc translation unit, blocking parallel C++
compilation. This adds an opt-in mechanism to split a huge union into
shard proto files, controlled per worksheet (rather than by a global
CLI flag).

Wiring:
  - tableau.proto WorksheetOptions +field 25 (union_split_threshold)
    and +field 26 (union_split_shard_size).
  - metabook.proto Metasheet +field 26/27 with matching metasheet
    column names, so users can toggle the behavior directly from a
    workbook's @tableau metasheet.
  - internal/importer/book/sheet.go: copy Meta.UnionSplitThreshold /
    UnionSplitShardSize into ws.Options during metasheet parsing.
  - internal/protogen/exporter.go:
    * bookExporter gains auxFiles / auxImportPaths, and a new
      writeAuxFile emits each shard as a fresh top-level proto with
      matching header (editions or proto3), imports and file options.
    * exportUnion collects candidate sub-messages, decides shouldSplit
      based on the per-sheet threshold, and either keeps the original
      nested behaviour or delegates to a new exportUnionSplit.
    * exportUnionSplit renames extracted sub-messages to
      '<Union>T_<SubType>' and packs them into shards of at most
      shard_size messages each (default 40).
  - internal/protogen/exporter_test.go: golden test 'export-union-split'
    covers the new emitter path; existing golden cases are untouched.

The 'T_' separator between the parent union name and the extracted
sub-message name is intentional: it avoids colliding with
protoc-gen-go's oneof wrapper naming (<Parent>_<CamelField>), which
is only disambiguated intra-file. Renaming only applies to locally
defined sub-messages (field.FullType == ''); predefined external
types keep their original fully-qualified names.
@hazy-wu
hazy-wu force-pushed the feat/per-sheet-union-split branch from e8b3325 to 7d152ae Compare August 4, 2026 06:22
Comment thread proto/tableau/protobuf/tableau.proto Outdated
Comment on lines +216 to +241
// Threshold for splitting a huge union sheet into auxiliary shard proto
// files. Only takes effect when this sheet's mode is
// MODE_UNION_TYPE / MODE_UNION_TYPE_MULTI. When the number of sub-messages
// in the union exceeds this threshold, sub-messages are extracted out of
// the union as top-level messages named `<Union>T_<SubType>` placed in
// shard files, and the main proto imports those shards. This substantially
// shrinks any single generated `.pb.cc` translation unit and lets the C++
// back-end compile the shards in parallel.
//
// Extracted sub-messages are renamed to `<Union>T_<SubType>` (the "T_"
// separator avoids colliding with protoc-gen-go's oneof wrapper naming
// `<Parent>_<CamelField>`, which is only disambiguated intra-file). Code
// that only depends on the union's `Type` enum and generated field
// accessors is unaffected by this rename.
//
// Set to 0 to disable (the default): the union is emitted with all
// sub-messages nested inline as usual.
//
// Default: 0 (disabled).
int32 union_split_threshold = 25;
// Number of extracted sub-messages placed in each shard file when
// `union_split_threshold` triggers splitting. Only used when
// `union_split_threshold > 0`.
//
// Default: 40.
int32 union_split_shard_size = 26;

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.

No need to support globally.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

To clarify "no globally": should UnionShardSize live only in Metasheet (with a new internal-only field on internal.Worksheet to reach the exporter), or is keeping it in WorksheetOptions acceptable?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I suggest in internal.Worksheet only.

Comment on lines +167 to +188
// Threshold for splitting a huge union sheet into auxiliary shard proto
// files. Only takes effect when this sheet's Mode is
// MODE_UNION_TYPE / MODE_UNION_TYPE_MULTI. When the number of sub-messages
// in the union exceeds this threshold, sub-messages are extracted out of
// the union as top-level messages named `<Union>T_<SubType>` placed in
// shard files. This shrinks the biggest generated `.pb.cc` translation
// unit and enables parallel C++ back-end compilation for huge unions.
//
// Set to 0 to disable (the default).
int32 union_split_threshold = 26 [(tableau.field) = {
name: "UnionSplitThreshold"
prop: {optional: true}
}];
// Number of extracted sub-messages placed in each shard file when
// `union_split_threshold` triggers splitting. Only used when
// `union_split_threshold > 0`.
//
// Default: 40.
int32 union_split_shard_size = 27 [(tableau.field) = {
name: "UnionSplitShardSize"
prop: {optional: true}
}];

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.

Just one option is enough:

 // Number of oneof message defintions placed in each shard file for Union Type definition. 
  //
  // Default: 50.
  int32 union_shard_size = 26 [(tableau.field) = {
    name: "UnionShardSize"
    prop: {optional: true}
  }];

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.

DO NOT ADD verbose T_, use <Union><SubType> instead for clean code and protobuf conventions.
<Union>T_<SubType> -> <Union><SubType>

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.

Missing functest for protogen at https://github.com/tableauio/tableau/tree/master/test/functest

Add csv files for Union type definiton, and use the new option "UnionShardSize"

@Kybxd Kybxd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for this PR! The overall direction looks good — the per-sheet split strategy is the right level of granularity. Below are the detailed review comments based on discussing with the team.


1. (Blocking) Merge two config fields into a single union_shard_size

Files: proto/tableau/protobuf/tableau.proto, proto/tableau/protobuf/internal/metabook.proto, internal/protogen/exporter.go

As wenchy pointed out, there is no need for two separate fields (union_split_threshold + union_split_shard_size). A single field is sufficient:

// Number of oneof sub-messages placed in each shard file for Union Type definition.
// Set to 0 to disable splitting (the default).
// Default: 0 (disabled).
int32 union_shard_size = 25;

Semantics:

  • union_shard_size = 0 (default) → no split, original inline behavior
  • union_shard_size > 0 → split enabled, each shard holds up to N sub-messages

This also simplifies the judgment logic in exportUnion():

shardSize := int(x.ws.GetOptions().GetUnionShardSize())
shouldSplit := shardSize > 0

2. (Blocking) Remove the T_ separator — use flat <Union><SubType> directly

File: internal/protogen/exporter.go

We evaluated two alternatives:

Option A (recommended) — flat concatenation: <Union><SubType>, e.g. TaskTargetPvpBattle
Option B — nested wrapper: <Union><ShardN> enclosing <SubType>, e.g.

message TaskTarget1 {
  message PvpBattle { ... }
}

We recommend Option A. Option B adds unnecessary nesting, making the oneof field declarations more verbose (TaskTarget1.PvpBattle pvp_battle = 1) without solving a real problem — shard files already isolate namespaces via the file path.

Regarding the T_ collision rationale from the current PR description: protoc-gen-go's oneof wrapper naming (<Parent>_<CamelField>) is an internal Go type name, not a proto-level name. It does not collide with proto-level message definitions across files, since protoc resolves types file-by-file via imports. Therefore <Union><SubType> is safe to use without T_.

Actions:

  • Delete constant unionSplitTypeSep
  • In exportUnion(), change oneof field type to x.ws.Name + typ
  • In exportUnionSplit(), change prefixedTyp to x.ws.Name + sm.typ

3. (Blocking) Missing functest

Directory: test/functest/

A functest case is needed. Add a CSV file defining a Union type sheet with UnionShardSize set and verify the end-to-end proto generation output.

4. (Blocking) Remove defaultUnionSplitShardSize — split only when user explicitly sets a positive value

File: internal/protogen/exporter.go

The current code has defaultUnionSplitShardSize = 40, which means setting union_split_threshold > 0 but leaving union_split_shard_size = 0 still triggers splitting. Per discussion, the user's explicit opt-in should be required for every aspect: union_shard_size = 0 means no split at all. Delete the defaultUnionSplitShardSize constant and the fallback logic.

5. (Blocking) Extract common proto file header generation to eliminate duplication

File: internal/protogen/exporter.go

export() and the new writeAuxFile() both duplicate ~30 lines of header generation code (the "Code generated by tableau" comment, syntax/edition, package, sorted imports, sorted file options). Extract this into a shared method:

func (x *bookExporter) writeProtoHeader(p *printer.Printer, imports map[string]bool) {
    // comment, syntax/edition, package, sorted imports, sorted file options
}

Both export() and writeAuxFile() should call this method. writeAuxFile() then becomes much simpler: call writeProtoHeader → write the shard body.

6. (Suggestion) Add boundary test cases

File: internal/protogen/exporter_test.go

The current export-union-split test only covers threshold=1, shardSize=1 with 2 sub-messages. Consider adding:

  • shardSize=0 → no split (default)
  • shardSize=1 with only 1 sub-message → single shard
  • Mixed local types and external predefined types → verify external types are excluded from subMsgs

7. (Suggestion) Use filepath.Ext for shard file naming

File: internal/protogen/exporter.go, exportUnionSplit()

The current strings.TrimSuffix(mainPath, x.be.FilenameSuffix+".proto") is fragile when FilenameSuffix is empty or non-standard. Use filepath.Ext + filepath.Base instead:

mainPath := x.be.GetProtoFilePath()
ext := filepath.Ext(mainPath)
base := strings.TrimSuffix(filepath.Base(mainPath), ext)
shardTag := strcase.FromContext(x.be.gen.ctx).ToSnake(x.ws.Name)
shardRelPath := fmt.Sprintf("%s_%s_%d%s", base, shardTag, shardIdx+1, ext)

Keep the shardTag — it is necessary to disambiguate when a single workbook proto contains multiple union sheets.

8. (Confirmed) No additional mode validation needed

No need to add a warning when union_shard_size is set on non-union sheets. Other parameters (e.g. Index only effective for MODE_DEFAULT) already follow this pattern of being silently ignored for inapplicable modes.


Summary of action items:

# Type Summary
1 Blocking Merge into single union_shard_size field, >0 enables, =0 disables
2 Blocking Use <Union><SubType> flat naming, remove T_ separator
3 Blocking Add functest
4 Blocking Remove defaultUnionSplitShardSize, no implicit default
5 Blocking Extract shared writeProtoHeader to eliminate header duplication
6 Suggestion Add boundary test cases
7 Suggestion Use filepath.Ext for shard file naming
8 Confirmed No mode validation needed

Merge union split threshold and shard size into a single Worksheet.union_shard_size field (moved out of WorksheetOptions); drop the "T_" type separator in favor of <Union><SubType> naming; extract writeProtoHeader to dedupe header generation; name shard files via filepath.Ext; emit (tableau.workbook) option on shard protos and let confgen skip files with no worksheet messages; assign field numbers to extracted sub-messages; add functest and edge-case unit tests.
@codecov

codecov Bot commented Aug 14, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.59124% with 17 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.69%. Comparing base (7bc5b61) to head (4c9a8a7).
⚠️ Report is 3 commits behind head on master.

Files with missing lines Patch % Lines
internal/protogen/exporter.go 87.21% 8 Missing and 9 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #444      +/-   ##
==========================================
+ Coverage   75.63%   75.69%   +0.06%     
==========================================
  Files          88       88              
  Lines        9531     9679     +148     
==========================================
+ Hits         7209     7327     +118     
- Misses       1747     1765      +18     
- Partials      575      587      +12     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Fix union split oneof rewriting to handle custom-named structs and reused local types (not just empty-Type structs); keep scalar and predefined types unchanged. Add unit cases and functest golden (SplitTarget with scalar/predefined/reused/custom-named types) covering two shard files.
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.

3 participants