Skip to content

Commit 0a869b9

Browse files
authored
[finelog] Split the native server ext into a marin-finelog-server dist (#6324)
## What `cd lib/finelog && uv run` used to recompile the whole Rust tree on every `pyproject.toml` mtime change (any pull or branch switch cost ~3 min), because `marin-finelog` was a maturin project bundling the pure-Python client with the native ext. This splits the package in two and fixes the dependency direction. ### Two dists - **`marin-finelog-server`** — new maturin dist rooted at `lib/finelog/rust`, shipping the in-process server as the top-level module `finelog_server` (top-level because an editable `src/finelog` would shadow a nested `finelog/_native.so` in site-packages). Its `tool.uv` cache-keys cover the Rust sources, so dev-mode source builds rebuild on Rust edits, not on every `pyproject` mtime. - **`marin-finelog`** — back to pure hatchling (client/deploy/proto + config). In-dir `uv run` now builds a pure wheel in ~1 s and takes the extension from PyPI by default. ### The client no longer depends on the server The pure `marin-finelog` client never needs the in-process native server — only consumers that *start* it do. So `marin-finelog-server` is **not** a runtime dependency of the client: - `lib/finelog`: the server moves into the `dev` group (needed only by the embedded-server smoke test, the dashboard demo, and to bind the `rust_mode.py dev` path source). - `lib/iris`: the controller — the one consumer of the embedded server — depends on `marin-finelog-server` explicitly. Root `marin` reaches it transitively via `marin-iris`. - Floors move to the stable `>= 0.2.10` pair, so the now-pointless `marin-finelog-server` prerelease opt-in is dropped from the root `constraint-dependencies` and the iris `Dockerfile`. ### Build resilience `build_package.py` fetched zig from a single hard-coded community mirror (`pkg.earth`) with no retry/fallback — a transient mirror 500 failed the whole wheel build (it just did, on this PR). It now rotates through several community mirrors with retries and falls back to the official ziglang.org server only if every mirror fails. ## Release / deploy order `finelog-release-wheels.yaml` publishes both dists in lockstep at one resolved version. The published `marin-finelog 0.2.9` is the *old fat wheel* (`finelog._native`); the new code imports `finelog_server`, so consumers must move to a coherent new version — hence a **stable 0.2.10 pair**. 1. On PyPI, add a **pending trusted publisher** for `marin-finelog-server` (project `marin-finelog-server`, owner `marin-community`, repo `marin-community/marin`, workflow `finelog-release-wheels.yaml`, environment `pypi-publish`). `marin-finelog` is already configured. 2. Dispatch `finelog-release-wheels` (`mode=stable`, `version=0.2.10`) on this branch to cut the stable pair. 3. `uv lock` (user mode) now resolves the pair → commit the refreshed `uv.lock`. CI's `uv sync --frozen` jobs go green and the PR is mergeable. Until step 2, root/iris in-dir work needs `scripts/rust_mode.py dev` (path sources build the server locally); the `dupekit-unit` user-mode guard checks both marker files.
1 parent 70f38ed commit 0a869b9

21 files changed

Lines changed: 348 additions & 160 deletions

File tree

.github/workflows/dupekit-unit.yaml

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,14 @@ jobs:
3737
# is why they keep 'uv.lock' here and dupekit does not.
3838
- 'lib/dupekit/**'
3939
- '.github/workflows/dupekit-unit.yaml'
40-
# The dev-mode guard below inspects the root pyproject.toml, where a
41-
# leaked editable rust source (written by scripts/rust_mode.py dev)
42-
# lands — it never rewrites uv.lock. Gate the guard on that file
43-
# directly so it runs whenever its target changes, without dragging
44-
# the expensive test/cargo jobs onto every root-pyproject edit.
40+
# The dev-mode guard below inspects the pyprojects where a leaked
41+
# rust path source (written by scripts/rust_mode.py dev) lands —
42+
# it never rewrites uv.lock. Gate the guard on those files directly
43+
# so it runs whenever its targets change, without dragging the
44+
# expensive test/cargo jobs onto every root-pyproject edit.
4545
user_mode:
4646
- 'pyproject.toml'
47+
- 'lib/finelog/pyproject.toml'
4748
4849
check-user-mode:
4950
needs: changes
@@ -54,12 +55,12 @@ jobs:
5455
- name: Checkout code
5556
uses: actions/checkout@v5
5657

57-
- name: Ensure pyproject.toml is in user mode
58+
- name: Ensure pyprojects are in user mode
5859
run: |
59-
# The RUST-DEV SOURCES block governs both native packages (dupekit and
60-
# finelog); either editable path source committed means dev mode leaked.
61-
if grep -qE 'marin-(dupekit|finelog) = \{ path' pyproject.toml; then
62-
echo "ERROR: pyproject.toml has a dev-mode rust source. Run 'python scripts/rust_mode.py user' before committing."
60+
# The RUST-DEV SOURCES blocks govern the native packages (dupekit and
61+
# the finelog pair); any path source committed means dev mode leaked.
62+
if grep -qE 'marin-(dupekit|finelog|finelog-server) = \{ path' pyproject.toml lib/finelog/pyproject.toml; then
63+
echo "ERROR: a pyproject.toml has a dev-mode rust source. Run 'python scripts/rust_mode.py user' before committing."
6364
exit 1
6465
fi
6566

.github/workflows/finelog-release-wheels.yaml

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,23 @@ name: "Finelog - Release Wheels"
55
# There is intentionally no `push: branches: [main]` trigger - that would let an
66
# auto-pin chore PR recursively re-trigger this workflow on every merge.
77
#
8-
# marin-finelog is a native package: the wheel bundles the pure-Python
9-
# client/deploy/proto AND the native in-process server ext (finelog._native),
10-
# built by maturin from lib/finelog (manifest-path -> rust/pyext). The nightly
11-
# and tag builds publish a wheel covering both trees, so the stale-nightly check
8+
# finelog ships as TWO dists, published in lockstep at one resolved version:
9+
# - marin-finelog-server: the native in-process server ext (importable as
10+
# `finelog_server`), built by maturin from lib/finelog/rust (manifest-path
11+
# -> pyext). Platform wheels come from the build matrix below.
12+
# - marin-finelog: the pure-Python client/deploy/proto (hatchling). Its single
13+
# py3-none-any wheel is built on the linux matrix leg only, so merged
14+
# artifacts never collide. It does NOT depend on marin-finelog-server;
15+
# consumers needing the in-process server depend on it explicitly.
16+
# PyPI trusted publishing must be configured for BOTH projects against this
17+
# workflow + the pypi-publish environment.
18+
#
19+
# The nightly and tag builds publish both dists, so the stale-nightly check
1220
# below scopes broadly to all of lib/finelog/. The pull_request leg, by
13-
# contrast, is only a build smoke ("does the wheel still compile?"), so its
14-
# paths filter is scoped to the inputs that can actually break the build (Rust
15-
# crate + maturin config + build driver) — not pure-Python edits under src/,
16-
# which the cross-compile bundles but cannot fail on.
21+
# contrast, is only a build smoke ("do the wheels still build?"), so its
22+
# paths filter is scoped to the inputs that can actually break the builds (Rust
23+
# crate + maturin/hatchling config + build driver) — not pure-Python edits
24+
# under src/, which the pure wheel bundles but cannot fail on.
1725
on:
1826
workflow_dispatch:
1927
inputs:
@@ -171,9 +179,10 @@ jobs:
171179
path: dist
172180
merge-multiple: true
173181

174-
# Build sdist into the same dist/ directory the wheels were downloaded
175-
# into so gh-action-pypi-publish uploads everything as one release.
176-
- name: Build sdist
182+
# Build both sdists into the same dist/ directory the wheels were
183+
# downloaded into so gh-action-pypi-publish uploads everything as one
184+
# release.
185+
- name: Build sdists
177186
run: |
178187
python lib/finelog/build_package.py \
179188
--mode "${{ needs.resolve.outputs.mode }}" \

lib/finelog/AGENTS.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,29 @@ Start with the shared instructions in `/AGENTS.md`. Finelog-specific notes:
3434
- Keys are opaque strings. Any structure (`/system/...`, `/user/<job>/<task>:<attempt>`)
3535
is iris-side convention; finelog does not parse keys.
3636

37+
## Packaging
38+
39+
Finelog ships as two PyPI dists, released in lockstep by
40+
`finelog-release-wheels.yaml`:
41+
42+
- `marin-finelog` — pure Python (this directory; hatchling).
43+
- `marin-finelog-server` — the native in-process server ext, importable as
44+
top-level `finelog_server` (maturin project at `rust/`; the cdylib crate is
45+
`rust/pyext`). Only `src/finelog/embedded.py` imports it.
46+
47+
`marin-finelog` does **not** depend on `marin-finelog-server` at runtime — the
48+
pure client never needs the in-process server. Consumers that do (the iris
49+
controller) depend on `marin-finelog-server` explicitly. Here it is only a
50+
`dev` dependency, pulled in for the embedded-server smoke test and the
51+
dashboard demo.
52+
53+
By default the extension comes from the pre-built PyPI wheel, so in-dir
54+
`uv run` never compiles Rust. To build it from source (live Rust dev), run
55+
`python scripts/rust_mode.py dev` at the repo root — it points
56+
`marin-finelog-server` at the local `rust/` tree in both the root and
57+
`lib/finelog` pyprojects. Run `python scripts/rust_mode.py user` before
58+
committing.
59+
3760
## Development
3861

3962
```bash

lib/finelog/build_package.py

Lines changed: 103 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,35 @@
22
# Copyright The Marin Authors
33
# SPDX-License-Identifier: Apache-2.0
44

5-
"""Build (and let CI publish) marin-finelog wheels.
5+
"""Build (and let CI publish) the marin-finelog dists.
66
77
Driven by .github/workflows/finelog-release-wheels.yaml. Mirrors
88
lib/dupekit/build_package.py: same nightly/stable/manual mode split and the
99
same zig-cross-compiled manylinux + native macOS wheel matrix.
1010
11-
marin-finelog is a native package: lib/finelog/pyproject.toml is a maturin
12-
project whose `[tool.maturin] manifest-path` points at the rust/pyext
13-
cdylib crate (the in-process `finelog._native` server). maturin therefore reads
14-
the wheel version from `[project] version` in lib/finelog/pyproject.toml and
15-
bundles the pure-Python client/deploy/proto alongside the native extension.
11+
finelog ships as TWO dists, released in lockstep at one resolved version:
12+
- marin-finelog-server: the native package. lib/finelog/rust/pyproject.toml
13+
is a maturin project whose `[tool.maturin] manifest-path` points at the
14+
pyext cdylib crate (the in-process server, importable as `finelog_server`).
15+
Platform wheels are built per-target by the CI matrix.
16+
- marin-finelog: pure Python (hatchling) — client/deploy/proto. One
17+
py3-none-any wheel, built on the linux matrix leg only so artifacts never
18+
collide across legs. It does NOT depend on marin-finelog-server; consumers
19+
that need the in-process server (e.g. iris) depend on it explicitly.
1620
1721
Modes:
1822
nightly -- `<bumped_patch>-dev.<YYYYMMDDhhmm>` (UTC), where
1923
`<bumped_patch>` is one patch above max(pyproject version,
20-
latest stable on PyPI). Sorting above the current stable is
21-
what lets `marin-finelog >= 0.2.0.dev0` in root pyproject.toml
22-
resolve to the latest dev. pyproject never needs to be
23-
re-bumped after a stable cut.
24+
latest marin-finelog stable on PyPI; the server dist follows
25+
the same value). Sorting above the current stable is what lets
26+
`marin-finelog >= 0.2.0.dev0`-style floors resolve to the
27+
latest dev. pyproject never needs to be re-bumped after a
28+
stable cut.
2429
stable -- version supplied via --version (extracted from the tag in CI).
25-
pyproject.toml is rewritten on disk so maturin builds with that
26-
version; the change is not committed.
30+
Both pyprojects are rewritten on disk so the builds carry that
31+
version; the change is not committed. A stable tag therefore
32+
cuts a stable PAIR, so stable-only consumers never need a
33+
prerelease opt-in for the server dist.
2734
manual -- `<pyproject>+<sha>` (PEP 440 local version). Build-only smoke
2835
for PRs and ad-hoc dev; PyPI rejects local-version identifiers,
2936
so the publish job declines to run in this mode.
@@ -50,19 +57,33 @@
5057
from pathlib import Path
5158

5259
FINELOG_DIR = Path(__file__).resolve().parent
60+
SERVER_DIR = FINELOG_DIR / "rust"
5361
REPO_ROOT = FINELOG_DIR.parent.parent
54-
# maturin reads the wheel version from `[project] version` here, and the
55-
# `[tool.maturin] manifest-path` (relative to this file) selects the cdylib.
62+
# The pure dist's pyproject is the canonical version source; the resolved
63+
# version is stamped into both files so the wheels agree.
5664
PYPROJECT_PATH = FINELOG_DIR / "pyproject.toml"
65+
SERVER_PYPROJECT_PATH = SERVER_DIR / "pyproject.toml"
5766
DIST_DIR = REPO_ROOT / "dist"
5867
TOOLS_DIR = REPO_ROOT / ".tools"
5968

6069
PYPI_JSON_URL = "https://pypi.org/pypi/marin-finelog/json"
6170

6271
ZIG_VERSION = "0.15.2"
63-
# ziglang.org's own server is very slow (<0.1 MB/s); use a community mirror
64-
# from https://ziglang.org/download/community-mirrors.txt instead.
65-
ZIG_DOWNLOAD_BASE = "https://pkg.earth/zig"
72+
# Zig tarballs are large and ziglang.org's own server is slow and rate-limited
73+
# (<0.1 MB/s), so prefer the community mirrors from
74+
# https://ziglang.org/download/community-mirrors.txt and fall back to the
75+
# official server only if every mirror fails. Mirrors intermittently 500 or
76+
# drop the connection (a single hard-coded mirror is a CI flake waiting to
77+
# happen), so we rotate through several with retries. Each mirror serves the
78+
# tarball at `<base>/<filename>`; the official server nests it under
79+
# `/download/<version>/`.
80+
ZIG_MIRRORS = (
81+
"https://pkg.earth/zig",
82+
"https://pkg.hexops.org/zig",
83+
"https://zig.linus.dev/zig",
84+
)
85+
ZIG_OFFICIAL_BASE = "https://ziglang.org/download"
86+
ZIG_DOWNLOAD_ATTEMPTS_PER_SOURCE = 2
6687

6788
# (rust-triple, manylinux-tag) — manylinux is None for native macOS builds.
6889
LINUX_TARGETS: list[tuple[str, str | None]] = [
@@ -97,8 +118,31 @@ def _zig_platform_key() -> str:
97118
return f"{arch_map[machine]}-{os_map[system]}"
98119

99120

121+
def _download_zig_archive(filename: str, dest: Path, reporthook) -> None:
122+
"""Fetch the zig tarball into ``dest``, trying mirrors then ziglang.org.
123+
124+
Tries each community mirror (a couple of attempts apiece, since they
125+
intermittently 500 or drop the connection) before falling back to the slow,
126+
rate-limited official server. Raises if every source fails.
127+
"""
128+
sources = [f"{base}/{filename}" for base in ZIG_MIRRORS]
129+
sources.append(f"{ZIG_OFFICIAL_BASE}/{ZIG_VERSION}/{filename}")
130+
last_error: Exception | None = None
131+
for url in sources:
132+
for attempt in range(1, ZIG_DOWNLOAD_ATTEMPTS_PER_SOURCE + 1):
133+
print(f"Downloading zig {ZIG_VERSION} from {url} (attempt {attempt})...")
134+
try:
135+
urllib.request.urlretrieve(url, dest, reporthook=reporthook)
136+
return
137+
except (urllib.error.URLError, OSError) as e:
138+
last_error = e
139+
print(f" download failed: {e}")
140+
dest.unlink(missing_ok=True)
141+
raise RuntimeError(f"Could not download zig {ZIG_VERSION} from any mirror or ziglang.org") from last_error
142+
143+
100144
def _ensure_zig() -> str:
101-
"""Return path to zig binary, downloading from a community mirror if absent."""
145+
"""Return path to zig binary, downloading it if absent (see _download_zig_archive)."""
102146
existing = shutil.which("zig")
103147
if existing:
104148
return existing
@@ -110,9 +154,6 @@ def _ensure_zig() -> str:
110154
return str(zig_bin)
111155

112156
filename = f"zig-{plat}-{ZIG_VERSION}.tar.xz"
113-
url = f"{ZIG_DOWNLOAD_BASE}/{filename}"
114-
print(f"Downloading zig {ZIG_VERSION} for {plat} from {ZIG_DOWNLOAD_BASE}...")
115-
116157
TOOLS_DIR.mkdir(parents=True, exist_ok=True)
117158
archive_path = TOOLS_DIR / filename
118159

@@ -131,7 +172,7 @@ def _report(block_num: int, block_size: int, total_size: int) -> None:
131172
else:
132173
print(f" zig download: {downloaded / 1e6:.1f} MB")
133174

134-
urllib.request.urlretrieve(url, archive_path, reporthook=_report)
175+
_download_zig_archive(filename, archive_path, _report)
135176
with tarfile.open(archive_path, "r:xz") as tar:
136177
tar.extractall(TOOLS_DIR, filter="data")
137178
archive_path.unlink()
@@ -163,18 +204,19 @@ def _ensure_maturin() -> str:
163204

164205

165206
def _maturin(*args: str, env: dict[str, str] | None = None) -> None:
166-
"""Run maturin from lib/finelog so it reads this package's pyproject.toml.
207+
"""Run maturin from lib/finelog/rust so it reads the server pyproject.toml.
167208
168-
The `[tool.maturin] manifest-path` in lib/finelog/pyproject.toml selects the
169-
rust/pyext cdylib crate; we deliberately do NOT pass --manifest-path
209+
The `[tool.maturin] manifest-path` in lib/finelog/rust/pyproject.toml
210+
selects the pyext cdylib crate; we deliberately do NOT pass --manifest-path
170211
(that would make maturin look for a sibling pyproject next to the crate).
171212
"""
172213
cmd = [_ensure_maturin(), *args]
173-
subprocess.run(cmd, check=True, cwd=FINELOG_DIR, env=env)
214+
subprocess.run(cmd, check=True, cwd=SERVER_DIR, env=env)
174215

175216

176-
# Match the `[project]` table's `version = "..."` — the first version key in
177-
# lib/finelog/pyproject.toml (the build-system table above it has none).
217+
# Match the `[project]` table's `version = "..."` — the first line-anchored
218+
# version key in each pyproject (the build-system table above it has none, and
219+
# dependency strings are indented so the anchor skips them).
178220
_VERSION_RE = re.compile(r'^(version\s*=\s*)"[^"]+"', re.MULTILINE)
179221

180222

@@ -188,12 +230,13 @@ def _read_project_version() -> str:
188230

189231

190232
def _write_project_version(new_version: str) -> None:
191-
text = PYPROJECT_PATH.read_text()
192-
new_text, n = _VERSION_RE.subn(rf'\1"{new_version}"', text, count=1)
193-
if n != 1:
194-
print(f"ERROR: Failed to rewrite version in {PYPROJECT_PATH}", file=sys.stderr)
195-
sys.exit(1)
196-
PYPROJECT_PATH.write_text(new_text)
233+
for path in (PYPROJECT_PATH, SERVER_PYPROJECT_PATH):
234+
text = path.read_text()
235+
new_text, n = _VERSION_RE.subn(rf'\1"{new_version}"', text, count=1)
236+
if n != 1:
237+
print(f"ERROR: Failed to rewrite version in {path}", file=sys.stderr)
238+
sys.exit(1)
239+
path.write_text(new_text)
197240

198241

199242
def _parse_semver(version: str) -> tuple[int, int, int]:
@@ -301,34 +344,52 @@ def _build_wheels(targets: list[tuple[str, str | None]], use_zig: bool) -> None:
301344
args.append("--zig")
302345
_maturin(*args, env=env)
303346

304-
_list_dist_artifacts("wheel(s)")
347+
348+
def _uv_build(*args: str) -> None:
349+
"""Build the pure dist with uv, dropping the .gitignore uv writes into the
350+
output dir — pypi-publish rejects non-distribution files in packages-dir,
351+
and both the artifact upload and the publish step glob all of dist/."""
352+
subprocess.run(["uv", "build", *args, "--out-dir", str(DIST_DIR)], check=True, cwd=FINELOG_DIR)
353+
(DIST_DIR / ".gitignore").unlink(missing_ok=True)
354+
355+
356+
def _build_pure_wheel() -> None:
357+
print("\n--- Building marin-finelog (pure) wheel ---")
358+
_uv_build("--wheel")
305359

306360

307361
def build_linux_wheels() -> None:
308362
_build_wheels(LINUX_TARGETS, use_zig=True)
363+
# The pure wheel is platform-independent; build it on this leg only so the
364+
# merged artifacts never contain two copies of the same filename.
365+
_build_pure_wheel()
366+
_list_dist_artifacts("wheel(s)")
309367

310368

311369
def build_macos_wheels() -> None:
312370
if platform.system() != "Darwin":
313371
print("ERROR: macOS wheels require a macOS host (zig can't cross-compile to macOS)", file=sys.stderr)
314372
sys.exit(1)
315373
_build_wheels(MAC_TARGETS, use_zig=False)
374+
_list_dist_artifacts("wheel(s)")
316375

317376

318-
def build_sdist() -> None:
377+
def build_sdists() -> None:
319378
# Adds to dist/ rather than resetting it: the release job downloads wheels
320379
# via download-artifact before invoking us, and we want them in the same
321380
# directory so `pypa/gh-action-pypi-publish` uploads everything together.
322381
DIST_DIR.mkdir(exist_ok=True)
323-
print("\n--- Building sdist ---")
382+
print("\n--- Building marin-finelog-server sdist ---")
324383
_maturin("sdist", "--out", str(DIST_DIR))
384+
print("\n--- Building marin-finelog sdist ---")
385+
_uv_build("--sdist")
325386
_list_dist_artifacts("sdist(s)")
326387

327388

328389
_BUILDERS = {
329390
"linux": build_linux_wheels,
330391
"macos": build_macos_wheels,
331-
"sdist": build_sdist,
392+
"sdist": build_sdists,
332393
}
333394

334395

@@ -360,14 +421,15 @@ def main() -> None:
360421
parser.error("--build is required unless --resolve-only is set")
361422

362423
version = args.version if args.version else resolve_version(args.mode, args.version)
363-
print(f"marin-finelog version: {version} (mode={args.mode})")
424+
print(f"marin-finelog / marin-finelog-server version: {version} (mode={args.mode})")
364425
_emit_github_output("version", version)
365426

366427
if args.resolve_only:
367428
return
368429

369-
# maturin reads version from [project] version in pyproject.toml. Stamp it
370-
# for the duration of this build; we never commit the change back.
430+
# Both builds read [project] version from their pyproject.toml. Stamp the
431+
# resolved version into both for the duration of this build; we never
432+
# commit the change back.
371433
_write_project_version(version)
372434
_BUILDERS[args.build]()
373435

lib/finelog/dashboard/scripts/demo.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,15 @@
88
Usage:
99
uv run python lib/finelog/dashboard/scripts/demo.py [--keep]
1010
11-
Boots the native ``finelog._native.EmbeddedServer`` (the same axum app the
11+
Boots the native ``finelog_server.EmbeddedServer`` (the same axum app the
1212
``finelog-server`` binary serves) on port 10001. Without ``--keep`` the script
1313
tears the server down on exit. With ``--keep`` the server stays up so the
1414
dashboard at http://localhost:10001/ can be inspected in a browser. The server
1515
serves the built dashboard from ``lib/finelog/dashboard/dist`` itself, so
1616
``npm run build`` must have run at least once.
1717
18-
Requires the native extension (a maturin/dev build of ``marin-finelog``).
18+
Requires the native extension (the marin-finelog-server wheel, or a
19+
rust_mode.py dev build).
1920
"""
2021

2122
from __future__ import annotations

0 commit comments

Comments
 (0)