Files
doris/build-support/check-extern-template-pairing.py
T
Mingyu Chen (Rayner)andClaude Fable 5 7933a82bdd [opt](build) Enforce BE compile-time hygiene at configure time (#66901)
### What problem does this PR solve?

Related PR: #66510 (the BE build-time series, #66615..#66826, that this
PR locks in)

Problem Summary:

The build-time series cut BE cold builds by ~49% and took
`olap_common.h` / `uid_util.h` off the everything-rebuilds line. Every
one of those wins can regress **silently**: a stray include, a lost
`extern template`, a header added to the PCH — the code still compiles,
only every build afterwards is slower, and nothing reports it. `#66052`
already showed the failure mode: the layering rules in
`check-header-deps.py` only worked because the author happened to
maintain them by hand; nothing in CI runs them.

This PR turns each known regression vector into a **fail-loud,
seconds-level gate that runs at configure time**, so a violation is a
build error on every path that builds the BE (CI, `build.sh`, the UT
build) with zero extra CI wiring — the same mounting pattern the FE side
already uses for its architecture gates (`exec-maven-plugin` running
`check-fe-core-metadata-funnel.sh` at `validate`).

**Checks** (pure text scans, ~1.3s combined, zero build dependency, zero
false positives on current master):

| Gate | Regression it stops |
|---|---|
| Header layering rules (30, +6 new) | a hub header (e.g. `exec_env.h`,
`function.h`, `pch.h`) re-growing an include edge into a subsystem that
was deliberately cut |
| Third-party ban table (new) |
`fmt`/`boost`/`concurrentqueue.h`/`<ranges>` returning to headers whose
bodies were moved out of line to contain template-instantiation cost
(`<ranges>` in a src header also breaks the `-fno-access-control` UT
build on libc++) |
| Two-axis budgets (new) | the edges nobody names: light hubs must stay
light (include-closure budget, zero slack), heavy payloads must not
spread (transitive-reach baseline +10%); `--closure` / `--reach`
subcommands rank the offending edges for the fix |
| pch whitelist (new) | any quoted include added to `pch/pch.h` — the
single most leveraged regression in the repo (touch one such header and
the whole backend plus the PCH rebuild) |
| `check-extern-template-pairing.py` (new) | the one **silent**
extern-template failure: a `.cpp` keeps the explicit instantiation but
the header loses the `extern` declaration, so every TU quietly
re-instantiates; also the loud direction (declaration with no
definition), reported at once instead of at link |
| `check-unity-skip-coverage.py` (new) | a test `#include`-ing a src
`.cpp` that is not opted out of unity batching — reported at configure
with the exact skip entry to add, instead of as duplicate symbols at the
end of the BE UT link |

`build-support/check-build-hygiene.sh` runs all three scripts;
`be/CMakeLists.txt` executes it at configure time. Escape hatches, by
design:

- `-DENABLE_BUILD_HYGIENE=OFF` (default ON; a missing `python3` is a
loud FATAL naming this switch, never a silent skip);
- every budget/whitelist/rule table lives in the scripts themselves, so
the legal way past a gate is a **one-line reviewed diff in the same PR**
— the gate is never a dead end. Every failure message carries the
mechanism (why this edge is expensive) and the concrete fix path.

**Found by the new gates on day one, fixed here:** `core/types.h` has
carried five `extern template struct fmt::formatter<Decimal*>;`
declarations with **no matching definition anywhere** since #53483 —
every member is defined in-class, so the linker never noticed; deleted.
Also made the FE funnel self-test's `sed -i` invocation portable to
BSD/macOS.

Blind spot, by design: instantiations expanded from macros (e.g.
`DECLARE_OPERATOR` in `operator.cpp`) are invisible to the pairing scan
on both sides — macro bodies are skipped; a comment at the site says so.

`be/README.md` (new) documents the gates, what to do when each fires,
and the `*_fwd.h` convention as the sanctioned way through a layering
barrier.

The same rules are encoded for AI-assisted development and review:
`be/AGENTS.md` (new — include discipline while writing BE code + review
checkpoints), `build-support/AGENTS.md` (new — maintenance discipline
for the gate scripts themselves), an include-src-cpp checkpoint in
`be/test/AGENTS.md`, and a Header Hygiene entry in the root `AGENTS.md`,
so coding agents and the review pipeline apply them automatically.

### Release note

None

### Check List (For Author)

- Test
    - [x] Manual test (add detailed scripts or steps below)
- `build-support/check-build-hygiene.sh` on master: all green, ~1.3s,
zero false positives.
- Self-tests `bash build-support/tests/run.sh` (7/7 PASS): red/green
injection per gate family — each seeded violation turns red with the
mechanism + fix in the message, restoring the file turns it green again.
- Real configure on macOS arm64: green path passes the gate section;
with an injected violation, configure dies at the gate in under a second
with the full message; `-DENABLE_BUILD_HYGIENE=OFF` bypasses; a missing
`python3` is a loud exit-2 FATAL naming the switch.
- This PR's own Linux CI exercises the configure mount on the second
platform.

- Behavior changed:
- [x] Yes. BE configure now fails fast (with mechanism + fix in the
message) on header-hygiene violations; emergency bypass via
`-DENABLE_BUILD_HYGIENE=OFF`. No runtime behavior change — the only
source-code changes are comments and the deletion of five never-defined
`extern template` declarations.

- Does this need documentation?
- [x] No. Developer-facing docs are included in-repo (`be/README.md`).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01EyZL631YTeuNXa9WLjvk7s

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 10:07:58 +08:00

272 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Pairing guard for `extern template` families.
The BE uses extern-template families (ColumnVector, ColumnDecimal, the operator
and serde stacks, ...) to instantiate hot template specializations once instead
of in every translation unit. Of all the ways such a pairing can rot, exactly
one is silent: a .cpp carries the explicit instantiation definition but the
header lost (or never gained) the matching `extern template` declaration. The
code still compiles and links -- every TU just quietly goes back to implicitly
instantiating that specialization, and the build slows down with no diagnostic
anywhere. This script turns both pairing directions into an error:
forward every `extern template` declaration in a be/src header must have a
matching explicit instantiation definition in some be/src .cpp
(missing ones eventually break the link, but only when a TU odr-uses
a non-inline member -- this reports them at once, with the file the
definition should go in).
reverse within a COVERED family -- a template name that already has at least
one extern declaration -- every explicit instantiation definition
must have a matching extern declaration (the silent case). Families
with no externs at all are intentionally out of scope: instantiation
without extern is a legitimate TU-local idiom there.
Matching is textual, on a normalized signature: whitespace folded, comments
stripped, namespace qualifiers dropped, known type aliases canonicalized, and
multi-line statements joined. Zero build dependency; runs in about half a
second.
Usage:
build-support/check-extern-template-pairing.py # enforce
build-support/check-extern-template-pairing.py --list # dump the pairing tables
"""
import argparse
import collections
import os
import re
import sys
SRC_ROOT = "be/src"
# Spellings that legitimately differ between a declaration site and its
# definition site are rewritten to one canonical form ON BOTH SIDES before
# matching. Because the rewrite is uniform, a pair that already spells its
# arguments consistently can never be broken by an entry here; entries are only
# ever needed when the header and the .cpp spell the same type differently.
# Keep this table small -- the better fix for a new mismatch is to spell both
# sides identically.
TYPE_ALIASES = {
# core/types.h vectorized aliases vs the underlying fixed-width types
# (column_string.h declares ColumnStr<UInt32>, column_string.cpp
# instantiates ColumnStr<uint32_t>).
"UInt32": "uint32_t",
"UInt64": "uint64_t",
# wide:: aliases vs the underlying wide::integer specializations
# (wide_integer_to_string.h declares to_string(const Int128&), the .cpp
# instantiates to_string(const integer<128, signed>&)).
"Int128": "integer<128,signed>",
"UInt128": "integer<128,unsigned>",
"Int256": "integer<256,signed>",
"UInt256": "integer<256,unsigned>",
}
# Escape hatch, deliberately empty. An entry is
# ("be/src/path/to/file.ext", "<normalized signature>")
# as printed by a failure (or by --list), and silences that one declaration or
# definition. Adding one must be a reviewed decision with a comment saying why
# the pairing is intentionally broken.
ALLOW = set()
EXTERN_START = re.compile(r"^\s*extern\s+template\b")
# An explicit instantiation begins `template` followed by anything but `<`
# (`template <...>` opens a template definition, not an instantiation).
DEF_START = re.compile(r"^\s*template\s+[A-Za-z_:]")
LINE_COMMENT = re.compile(r"//.*$")
BLOCK_COMMENT = re.compile(r"/\*.*?\*/")
def statements(path, start):
"""Yields (lineno, statement) for namespace-scope statements matching
`start`, joining continuation lines up to the terminating semicolon and
skipping preprocessor directives and macro bodies (an instantiation-shaped
line inside a #define is not an instantiation)."""
with open(path, encoding="utf-8", errors="ignore") as handle:
lines = handle.read().splitlines()
in_macro = False
i = 0
while i < len(lines):
raw = lines[i]
if in_macro or raw.lstrip().startswith("#"):
in_macro = raw.rstrip().endswith("\\")
i += 1
continue
if not start.match(raw):
i += 1
continue
stmt, j = raw, i
while ";" not in stmt and j + 1 < len(lines) and j - i < 20:
j += 1
stmt += " " + lines[j]
if ";" in stmt and "\\" not in stmt:
yield i + 1, stmt.split(";")[0].strip()
i = j + 1
def normalize(sig):
"""Collapses a declaration/definition to the comparable skeleton."""
s = BLOCK_COMMENT.sub(" ", sig)
s = LINE_COMMENT.sub(" ", s)
s = re.sub(r"\s+", " ", s).strip()
s = re.sub(r"^extern\s+", "", s)
s = re.sub(r"^template\s+", "", s)
s = re.sub(r"^(class|struct)\s+", "", s)
s = re.sub(r"\b[A-Za-z_]\w*::", "", s)
for alias, canonical in TYPE_ALIASES.items():
s = re.sub(rf"\b{alias}\b", canonical, s)
return s.replace(" ", "")
def family(norm):
"""The template name a normalized signature instantiates: the identifier
before the argument list for a type, the function name for a function."""
paren = norm.find("(")
if paren != -1:
m = re.search(r"([A-Za-z_]\w*)(?:<[^()]*>)?\($", norm[: paren + 1])
if m:
return m.group(1)
m = re.match(r"([A-Za-z_]\w*)<", norm)
return m.group(1) if m else norm
def walk(suffixes):
for directory, _, names in os.walk(SRC_ROOT):
for name in names:
if name.endswith(suffixes):
yield os.path.join(directory, name)
def collect():
externs, defs = [], []
for path in walk((".h", ".hpp")):
with open(path, encoding="utf-8", errors="ignore") as handle:
if "extern template" not in handle.read():
continue
for lineno, stmt in statements(path, EXTERN_START):
externs.append((path, lineno, stmt, normalize(stmt)))
for path in walk((".cpp", ".cc")):
for lineno, stmt in statements(path, DEF_START):
if re.match(r"^\s*extern\b", stmt):
continue
defs.append((path, lineno, stmt, normalize(stmt)))
return externs, defs
def enforce(externs, defs):
def_index = collections.defaultdict(list)
for path, lineno, stmt, norm in defs:
def_index[norm].append((path, lineno))
extern_index = collections.defaultdict(list)
covered = collections.defaultdict(set) # family -> headers declaring it
for path, lineno, stmt, norm in externs:
extern_index[norm].append((path, lineno))
covered[family(norm)].add(path)
failures = 0
for path, lineno, stmt, norm in externs:
if norm in def_index or (path, norm) in ALLOW:
continue
failures += 1
print(
"error: extern template declaration pairs with no explicit "
"instantiation definition",
file=sys.stderr,
)
print(f" decl: {path}:{lineno}: {stmt};", file=sys.stderr)
print(f" norm: {norm}", file=sys.stderr)
print(
" reason: 'extern template' promises the specialization is "
"instantiated in some .cpp; without one, any TU that odr-uses a "
"non-inline member fails to link -- and if every member is inline, "
"the declaration is dead weight",
file=sys.stderr,
)
print(
" fix: add the matching 'template class/struct/... ;' to the "
".cpp that owns this family, or delete the declaration; an "
"intentionally unpaired declaration goes in ALLOW with a comment",
file=sys.stderr,
)
for path, lineno, stmt, norm in defs:
fam = family(norm)
if fam not in covered or norm in extern_index or (path, norm) in ALLOW:
continue
failures += 1
headers = ", ".join(sorted(covered[fam]))
print(
"error: explicit instantiation lacks the matching 'extern "
"template' declaration",
file=sys.stderr,
)
print(f" def: {path}:{lineno}: {stmt};", file=sys.stderr)
print(f" norm: {norm}", file=sys.stderr)
print(f" family: {fam} is extern-covered in {headers}", file=sys.stderr)
print(
" reason: every extern-declared specialization of this family is "
"instantiated once, but this one re-instantiates in every "
"including TU -- the one pairing mistake that is silent: it "
"compiles, links, and only slows the build down",
file=sys.stderr,
)
print(
" fix: add 'extern template ...;' next to the family's other "
"externs in the header above, spelling the template arguments the "
"same way as the definition, or list the definition in ALLOW with "
"a comment",
file=sys.stderr,
)
return failures
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--list",
action="store_true",
help="dump every declaration and definition with its normalized form",
)
args = parser.parse_args()
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
os.chdir(root)
externs, defs = collect()
if args.list:
for path, lineno, stmt, norm in externs:
print(f"decl {path}:{lineno}\n {norm}")
for path, lineno, stmt, norm in defs:
print(f"def {path}:{lineno}\n {norm}")
return 0
failures = enforce(externs, defs)
if failures:
print(f"\n{failures} extern/instantiation pairing violation(s)", file=sys.stderr)
return 1
families = {family(n) for _, _, _, n in externs}
print(
f"extern/instantiation pairing: {len(externs)} declaration(s), "
f"{len(families)} covered family(s), all paired"
)
return 0
if __name__ == "__main__":
sys.exit(main())