-
Notifications
You must be signed in to change notification settings - Fork 0
309 lines (300 loc) · 14.2 KB
/
Copy pathci.yml
File metadata and controls
309 lines (300 loc) · 14.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
name: CI
on:
push:
branches: [main]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.14"
- run: pip install ruff==0.16.3
- run: ruff check .
- run: ruff format --check .
# rust-toolchain.toml is what picks the compiler, here and on
# every machine, so nothing installs one.
- run: cargo fmt --all --check
- run: cargo clippy --all-targets -- -D warnings
# The other two ABIs the release builds, each a feature of this
# crate, compiled here so that a change which breaks one is found
# on the pull request rather than on the tag. The free-threaded
# stable ABI needs no interpreter at all, which is the point of a
# stable ABI and the only reason 3.15 can be built for before it
# is installable.
- run: cargo check --no-default-features --features pyo3/extension-module
- run: cargo check --no-default-features --features abi3t,pyo3/extension-module
env:
PYO3_NO_PYTHON: 1
# What the public surface was, against what it is. Griffe reads the
# package the way a reader does, out of the .py files and the .pyi
# that declares the compiled half, and it names what moved, changed
# shape or went away. Nothing is built here on purpose: the declared
# API is what the stub says, so the stub is what gets compared, and a
# stub that has drifted from the extension is what the typing tests in
# the suite are for.
#
# A break is allowed. It has to be paid for with a version bump, which
# is the rule cargo semver-checks applies to the engine and the rule
# api-extractor's committed report applies to the TypeScript client:
# the change is fine, the change happening quietly is not. So this
# compares the version too and steps aside once it has moved, after
# printing what moved with it.
api:
runs-on: ubuntu-latest
steps:
# Griffe builds a worktree at the baseline, so it wants the ref
# and not just the one commit under test.
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-python@v6
with:
python-version: "3.14"
- run: pip install griffe==2.2.0
# The gate is validated the only way a gate can be: a break has to
# fail it. Renaming a class the DB-API layer inherits from is the
# cheapest break that is unambiguously one, and the tree is put
# back before anything else looks at it.
- name: A break the gate is meant to catch, caught
env:
BASE_REF: ${{ github.base_ref }}
run: |
base="${BASE_REF:+origin/$BASE_REF}"
base="${base:-HEAD^}"
sed -i 's/^class DataError(/class DataErrorRenamed(/' python/zudb/errors.py
set +e
griffe check zudb -s python --against "$base"
rc=$?
set -e
git checkout -- python/zudb/errors.py
test $rc -ne 0 || { echo "the api gate did not fire on a removed name"; exit 1; }
- name: The surface, against what it was
env:
BASE_REF: ${{ github.base_ref }}
run: |
base="${BASE_REF:+origin/$BASE_REF}"
base="${base:-HEAD^}"
was=$(git show "$base:pyproject.toml" | sed -n 's/^version = "\(.*\)"/\1/p' | head -1)
now=$(sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml | head -1)
if [ "$was" != "$now" ]; then
echo "version moved from $was to $now, so a break here is one that was declared"
griffe check zudb -s python --against "$base" || true
exit 0
fi
griffe check zudb -s python --against "$base"
test:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
python: ["3.11", "3.14"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python }}
- uses: Swatinem/rust-cache@v2
# Installed as a wheel rather than developed in place, so what
# the suite imports is what a person who runs `pip install zudb`
# gets: the extension out of a wheel, the package out of
# site-packages, and nothing resolved out of the checkout.
- run: pip install ".[all]"
# griffe reads the stub and inspects the installed extension, so
# the check runs against the wheel rather than against the
# checkout it was built from. pdoc is here for the same reason:
# the reference is generated from the installed package, and the
# test that says so has nothing to read without it.
- run: pip install pytest griffe ipython pdoc==16.0.0
- run: pytest
# The same suite again, over an extension built with AddressSanitizer.
#
# There is no `unsafe` in this crate, which is the reason to run this
# rather than the reason not to: what a binding gets wrong is not
# arithmetic on a raw pointer but a buffer read after the object that
# owned it was collected, or an engine allocation freed on one side of
# the boundary and touched from the other. Neither is an `unsafe`
# block here and both are a use-after-free.
#
# ASan's runtime has to be loaded before anything it instruments, and
# the extension is opened by `import` long after python has started,
# so it is preloaded rather than linked: `-Zexternal-clangrt` tells
# rustc not to bundle its own copy and LD_PRELOAD supplies clang's.
# `--target` is what keeps the flags off the build scripts, which are
# host programs with no runtime preloaded and would not link.
# Leak detection is off, because LSan reports the interpreter's own
# hundred one-time allocations and cannot be told to stop; the job
# below is where leaks are counted, with a tool that can.
sanitizer:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.14"
- uses: Swatinem/rust-cache@v2
# The instrumented build is nightly, because `-Zsanitizer` is, and
# it is the only thing here that is: what ships is built by the
# pinned compiler in every other job.
- run: rustup toolchain install nightly --profile minimal
- run: sudo apt-get update && sudo apt-get install -y libclang-rt-18-dev
# pandas and pyarrow by name rather than through the extra that
# names them, because installing the extra would build this
# extension from source to get at its metadata and this job builds
# its own a step later. They are here at all because two of the
# whole programs the README publishes call to_pandas, and a job
# that skipped them would be watching a smaller suite than the
# one it claims to watch.
- run: pip install maturin pytest ipython pandas pyarrow
- name: The extension, instrumented
env:
RUSTUP_TOOLCHAIN: nightly
RUSTFLAGS: -Zsanitizer=address -Zexternal-clangrt
CC: clang
CXX: clang++
run: |
maturin build --target x86_64-unknown-linux-gnu --out dist
pip install --force-reinstall --no-deps dist/*.whl
- name: The suite, watched
run: |
runtime=$(clang -print-file-name=libclang_rt.asan-$(uname -m).so)
test -f "$runtime"
LD_PRELOAD="$runtime" ASAN_OPTIONS=detect_leaks=0:abort_on_error=1 \
pytest -m "not timing"
# The third tool over the same suite, and the one that watches what
# the sanitizer cannot. ASan instruments the source it compiles, so it
# sees nothing the interpreter does with the memory it hands the
# extension; Valgrind instruments the instructions that run, so both
# sides of the boundary are watched and so is every prebuilt thing
# either of them links. It also reports a read of memory nobody wrote,
# which ASan does not look for at all.
#
# PYTHONMALLOC=malloc is what makes this readable. CPython pools small
# objects behind its own allocator by default, so every allocation
# this extension makes through the interpreter would arrive as one
# 256KB arena and nothing inside it could be attributed to anybody.
#
# tools/valgrind.supp is one rule and says what it leaves out. The
# gate is validated the only way a gate can be: a deliberate leak
# through ctypes fails it.
#
# Definite leaks and not possible ones, which is the same pair of
# flags the TypeScript client's job carries and for a sharper reason
# here. Over the whole suite this counts 420 possible losses and zero
# definite ones, and 417 of the 420 are pyarrow registering compute
# kernels into a static table it never tears down. A possible loss is
# a block whose only surviving pointer is into its middle, which is
# what a registry of C++ objects looks like from the outside and what
# almost nothing this extension allocates looks like. Counting them
# would mean either a red job or a suppression naming pyarrow, and
# naming a dependency in a suppression file is how a suppression file
# starts growing. What it costs is a Rust leak that happens to leave
# an interior pointer behind, and the suite counts live connections
# and process descriptors from the other side for that.
leaks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.14"
- uses: Swatinem/rust-cache@v2
- run: sudo apt-get update && sudo apt-get install -y valgrind
# The release build, with its symbols left on. Valgrind wants the
# instructions the extension actually ships, so this is the same
# optimised build and not an instrumented one, which would be a
# different program with a different allocator underneath it. What
# changes is only that the symbol table survives: a leak report
# whose every frame reads ??? says a number and nothing a person
# can act on, and the suppression file cannot name a function it
# has no name for.
#
# Three settings and not one, because two things strip this wheel
# and only one of them is cargo. `strip = true` in pyproject.toml
# is maturin's own, applied after the build and to the artifact
# rather than through the profile, so a cargo profile that says to
# keep the symbols is a cargo profile maturin then throws away.
# That is what happened the first time this job ran the suite: one
# eighty byte record out of pyo3's type constructor, named in the
# suppression file, reported with ??? on every frame and matched
# by nothing.
#
# Every optional dependency except polars, which starts a thread
# pool the moment it is imported and holds a block of thread-local
# state per worker for the life of the process. Valgrind reports
# all of it and none of it belongs to this client, and the one
# test that wants polars skips itself when it is missing. Pandas
# and pyarrow stay because the README examples this suite runs go
# through them.
- run: pip install ".[pandas]" pytest ipython
env:
MATURIN_STRIP: "false"
CARGO_PROFILE_RELEASE_STRIP: "false"
CARGO_PROFILE_RELEASE_DEBUG: "1"
# Said here rather than trusted, because the whole job rests on it
# and the way it fails is an hour of valgrind followed by a report
# nobody can read. The section is what valgrind reads a frame's
# name out of, and a stripped object has neither it nor a symbol
# table to fall back on.
- name: The symbols the report is read with
run: |
set -eu
so=$(python -c 'import zudb, pathlib; print(pathlib.Path(zudb.__file__).parent / "_zudb.abi3.so")')
readelf -S "$so" | grep -q debug_info \
|| { echo "$so carries no debug info, so every frame of the report would read ???"; exit 1; }
- name: A leak the job is meant to catch, caught
run: |
set +e
PYTHONMALLOC=malloc valgrind --error-exitcode=1 --leak-check=full \
--show-leak-kinds=definite --errors-for-leak-kinds=definite \
--suppressions=tools/valgrind.supp -q \
python -c 'import ctypes; ctypes.CDLL("libc.so.6").malloc(4096)'
test $? -eq 1 || { echo "the leak gate did not fire on a leak"; exit 1; }
- name: The suite, counted
run: |
PYTHONMALLOC=malloc valgrind --error-exitcode=1 --leak-check=full \
--show-leak-kinds=definite --errors-for-leak-kinds=definite \
--suppressions=tools/valgrind.supp -q \
python -m pytest -m "not timing"
# The shared corpus, which is the same 945 cases the engine runs
# against itself and the eight other clients run against theirs. It is
# a job of its own because it needs a second checkout, and it runs on
# one platform because what it is asking about is this client's value
# mapping rather than anything the operating system decides.
corpus:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v6
with:
python-version: "3.14"
- uses: Swatinem/rust-cache@v2
# The cases are versioned with the engine, so the revision comes
# out of the pin this client already builds against rather than
# being written down a second time and drifting.
- id: pin
run: |
rev=$(sed -n 's/^zudb = .*rev = "\([0-9a-f]*\)".*/\1/p' Cargo.toml)
test -n "$rev"
echo "rev=$rev" >> "$GITHUB_OUTPUT"
- uses: actions/checkout@v7
with:
repository: tamnd/zu
ref: ${{ steps.pin.outputs.rev }}
path: engine
- run: pip install . pytest
# The runner first, because its summary is the line a person
# reads, and the suite second, because it is the one that knows
# which cases this client is allowed to leave unanswered.
- run: python -m conformance engine/conformance/cases
- run: pytest tests/test_conformance.py
env:
ZU_CASES: engine/conformance/cases