Skip to content

Commit 1e390a4

Browse files
authored
Merge pull request #47 from VirtualFlyBrain/testing-changes
Changes to cache usage on PRs and versioning mechanism
2 parents 8845074 + 3382d8d commit 1e390a4

11 files changed

Lines changed: 324 additions & 34 deletions

File tree

.github/workflows/docker.yml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,22 +25,22 @@ jobs:
2525
echo "tag=$TAG" >> $GITHUB_OUTPUT
2626
2727
# Mirror what publish_to_pypi.yaml does so the docker image and the
28-
# PyPI wheel never disagree about their own version.
29-
- name: Sync setup.py / __init__.py version to tag
28+
# PyPI wheel never disagree about their own version. The version lives in a
29+
# single source file (src/vfbquery/_version.py); setup.py reads it at build
30+
# time and __init__.py imports it, so only _version.py is bumped here.
31+
- name: Sync version to tag
3032
id: version
3133
run: |
3234
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
3335
VERSION=${GITHUB_REF#refs/tags/v}
3436
echo "Tag build detected: syncing __version__ to $VERSION"
35-
sed -i "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" setup.py
36-
sed -i "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" src/vfbquery/__init__.py
37+
sed -i "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" src/vfbquery/_version.py
3738
else
38-
VERSION=$(grep '^__version__' setup.py | sed 's/.*"\(.*\)".*/\1/')
39+
VERSION=$(grep '^__version__' src/vfbquery/_version.py | sed 's/.*"\(.*\)".*/\1/')
3940
echo "Branch / dev build: using committed version $VERSION"
4041
fi
4142
echo "version=$VERSION" >> $GITHUB_OUTPUT
42-
echo " setup.py: $(grep ^__version__ setup.py)"
43-
echo " __init__.py: $(grep ^__version__ src/vfbquery/__init__.py)"
43+
echo " _version.py: $(grep ^__version__ src/vfbquery/_version.py)"
4444
4545
- name: Build test Docker image
4646
run: docker build --no-cache . --file Dockerfile --tag test-image

.github/workflows/performance-test.yml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ jobs:
6060
# IMPORTANT: the retry OVERWRITES performance_test_output.log so the
6161
# downstream "Fail job on test failures" step grades on the second
6262
# attempt's output only.
63+
env:
64+
# Read-only on PRs so a PR check never writes/purges the shared prod
65+
# cache; writable on push-to-main and scheduled runs so those refresh
66+
# and warm it (e.g. after a minor/major release). See
67+
# solr_caching_readonly().
68+
VFBQUERY_CACHE_READONLY: ${{ github.event_name == 'pull_request' && 'true' || 'false' }}
6369
run: |
6470
set +e
6571
echo "=== Performance test attempt 1/2 (parallel) ==="
@@ -80,6 +86,9 @@ jobs:
8086
if: always()
8187
env:
8288
VFBQUERY_CACHE_ENABLED: 'true'
89+
# Read-only on PRs (never write/purge the shared prod cache); writable
90+
# on push-to-main and scheduled runs so those refresh/warm it.
91+
VFBQUERY_CACHE_READONLY: ${{ github.event_name == 'pull_request' && 'true' || 'false' }}
8392
MPLBACKEND: 'Agg'
8493
VISPY_GL_LIB: 'osmesa'
8594
VISPY_USE_EGL: '0'
@@ -107,7 +116,11 @@ jobs:
107116
- name: Run Connectivity Tests
108117
if: always()
109118
env:
110-
VFBQUERY_CACHE_ENABLED: 'true'
119+
# Disable the result cache so the connectivity integration tests
120+
# validate the LIVE query against the database, rather than reading
121+
# (possibly stale) entries from the shared production cache or writing
122+
# this run's results back into it. See solr_caching_disabled().
123+
VFBQUERY_CACHE_ENABLED: 'false'
111124
MPLBACKEND: 'Agg'
112125
VISPY_GL_LIB: 'osmesa'
113126
VISPY_USE_EGL: '0'

.github/workflows/publish_to_pypi.yaml

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ jobs:
1010
runs-on: ubuntu-latest
1111
permissions:
1212
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
13+
contents: write # allows the post-publish step to commit the version bump back to main
1314
steps:
1415
- uses: actions/checkout@v4
1516
with:
@@ -43,22 +44,18 @@ jobs:
4344
# Set environment variables for the build
4445
echo "VERSION=$VERSION" >> $GITHUB_ENV
4546
46-
# Update version in setup.py
47-
echo "Updating version in setup.py to $VERSION"
48-
sed -i "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" setup.py
49-
50-
# Update version in package __init__.py
51-
echo "Updating version in src/vfbquery/__init__.py to $VERSION"
52-
sed -i "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" src/vfbquery/__init__.py
53-
54-
echo "Updated setup.py version:"
55-
grep "__version__" setup.py
56-
echo "Updated package version:"
57-
grep "__version__" src/vfbquery/__init__.py
47+
# Single source of truth: bump only _version.py. setup.py reads it at
48+
# build time and vfbquery.__init__ imports it at runtime, so the wheel
49+
# metadata, vfbquery.__version__ and the cache version stamp all follow.
50+
echo "Updating version in src/vfbquery/_version.py to $VERSION"
51+
sed -i "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" src/vfbquery/_version.py
52+
53+
echo "Updated version:"
54+
grep "__version__" src/vfbquery/_version.py
5855
else
5956
# Not running from a tag, show current version
60-
echo "Not running from a tag, using existing version from setup.py"
61-
grep "__version__" setup.py
57+
echo "Not running from a tag, using existing version from _version.py"
58+
grep "__version__" src/vfbquery/_version.py
6259
fi
6360
6461
- name: Build distributions
@@ -110,6 +107,33 @@ jobs:
110107
111108
- name: Publish distribution 📦 to PyPI
112109
uses: pypa/gh-action-pypi-publish@v1.12.2
110+
111+
- name: Commit version bump back to main
112+
# Runs only after a successful publish from a release tag. The build above
113+
# runs from a detached tag checkout, so here we switch to the live main
114+
# branch, re-apply the released version to the single source file
115+
# (_version.py) and push. [skip ci] keeps this housekeeping commit from
116+
# retriggering the test/perf workflows.
117+
if: success() && startsWith(github.ref, 'refs/tags/v')
118+
run: |
119+
set -e
120+
if [[ -z "$VERSION" ]]; then
121+
echo "VERSION not set; nothing to commit."
122+
exit 0
123+
fi
124+
git config user.email "action@github.com"
125+
git config user.name "GitHub Action"
126+
git fetch origin main
127+
git reset --hard origin/main
128+
sed -i "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" src/vfbquery/_version.py
129+
git add src/vfbquery/_version.py
130+
if git diff --staged --quiet; then
131+
echo "main already at version $VERSION; nothing to commit."
132+
else
133+
git commit -m "Bump version to $VERSION [skip ci]"
134+
git push origin HEAD:main
135+
fi
136+
113137
# - name: Publish package to TestPyPI
114138
# uses: pypa/gh-action-pypi-publish@release/v1
115139
# with:

CACHING.md

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,35 @@ Note: data reads in `vfb_queries.py` (term_info, painted domains, ontology
111111
label lookups, etc.) still go to `solr.virtualflybrain.org` — only the result
112112
*cache* moved. The two are independent.
113113

114+
## Cache versioning and invalidation
115+
116+
Every cache entry is stamped with the VFBquery package version (major.minor) that
117+
wrote it, so results from an old code version aren't served after an upgrade.
118+
119+
The **running** version is resolved (in `solr_result_cache.py`) as:
120+
121+
1. the `VFBQUERY_VERSION` environment variable if set, otherwise
122+
2. the installed package version (`importlib.metadata.version('vfbquery')`),
123+
124+
normalized to **major.minor**. That value comes from the single source of truth,
125+
`src/vfbquery/_version.py` (see [RELEASING.md](RELEASING.md)).
126+
127+
On read, if an entry's stamp differs from the running version, invalidation is
128+
**monotonic** — it only discards entries written by an *older* version:
129+
130+
- **Older (or unversioned) entry** → invalidated, deleted, and recomputed by the
131+
current code.
132+
- **Newer entry** (seen by a stale/older install, or by an older deploy running
133+
alongside a newer one) → treated as a miss but **not deleted**. An older client
134+
must never purge a fresher entry; the previous `!=` check did, which let
135+
downgrades wipe live entries and made concurrent versions thrash each other.
136+
137+
Consequences for the major.minor namespace:
138+
139+
- **Patch bumps** (`1.20.0 → 1.20.3`) share the cache — no invalidation.
140+
- **Minor/major bumps** (`1.20 → 1.21`) invalidate older entries on read, so a
141+
release that changes query output naturally refreshes the cache.
142+
114143
## Runtime Configuration
115144

116145
Control caching behavior:
@@ -133,6 +162,54 @@ Disable caching globally if needed:
133162
export VFBQUERY_CACHE_ENABLED=false
134163
```
135164

165+
When disabled, the cache layer is **fully bypassed** — every query runs live
166+
against Neo4j/Owlery/Solr with **no read, no write, no version-invalidation, and
167+
no contact with the cache server** (`solr_caching_disabled()` in
168+
`solr_result_cache.py`; mirrored in `vfb_connectivity.query_connectivity`).
169+
170+
This is how the **integration tests** run in CI. The test steps that assert on
171+
query *results* (`test_neuron_neuron_connectivity`, `test_neuron_region_connectivity`,
172+
`test_vfb_connectivity`, the unit tests in `python-test.yml`, and `examples.yml`)
173+
set `VFBQUERY_CACHE_ENABLED=false` so they:
174+
175+
- validate the **live** query for the branch under test, not a (possibly stale)
176+
cached result, and
177+
- never write a PR/branch's output back into the **shared production cache**.
178+
179+
The performance workflow's perf-timing steps keep caching enabled on purpose
180+
(they measure warm-cache latency); only the result-asserting steps disable it.
181+
182+
#### Read-only mode
183+
184+
```bash
185+
export VFBQUERY_CACHE_READONLY=true
186+
```
187+
188+
Read-only mode still **reads** the cache (warm results are served), but
189+
suppresses every **mutation** — no writes, no force-refresh clears, and no
190+
version/expiry purges (`solr_caching_readonly()`, gating `cache_result`,
191+
`clear_cache_entry` and `_clear_expired_cache_document`).
192+
193+
This is used by the **performance-test workflow's perf-timing steps**, but only
194+
on **pull requests**`VFBQUERY_CACHE_READONLY` is set from
195+
`github.event_name == 'pull_request'`. So:
196+
197+
- **On PRs** the perf steps read warm entries for representative timings but
198+
never write or purge. Combined with `VFBQUERY_CACHE_ENABLED=false` on the
199+
result-asserting steps, **no PR run can modify the production cache**.
200+
- **On push-to-`main` and scheduled runs** those perf steps are *writable*, so
201+
they refresh/warm the cache under the current `main` version.
202+
203+
That post-merge + daily-scheduled warming (plus lazy refresh by production
204+
traffic) is what keeps the cache populated for the version on `main`, including
205+
after a release bumps it. There's no dedicated release-triggered warm.
206+
207+
Caveat: a PR that bumps the **minor/major** version reads cold in read-only mode
208+
(its version's entries don't exist yet — see version invalidation below);
209+
same-version PRs read the already-warm production entries. If you'd rather PR
210+
checks read *and* write a cache without touching production, point them at a
211+
separate collection with `VFBQUERY_SOLR_URL` instead.
212+
136213
## Performance Benefits
137214

138215
VFBquery SOLR caching provides significant performance improvements:

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ result2 = vfb.get_term_info('FBbt_00003748') # 54,000x faster!
2828
similar = vfb.get_similar_neurons('VFB_jrchk00s') # Fast after first run
2929
```
3030

31+
📚 See [CACHING.md](CACHING.md) for cache configuration, the `VFBQUERY_CACHE_ENABLED`
32+
bypass (used by the tests), and version-based invalidation; and
33+
[RELEASING.md](RELEASING.md) for how the single-source version (`_version.py`) is
34+
bumped from the release tag.
35+
3136
To get term info for a term:
3237
get_term_info(ID)
3338

RELEASING.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# Releasing VFBquery
2+
3+
## Version: single source of truth
4+
5+
The package version lives in exactly one place:
6+
7+
```
8+
src/vfbquery/_version.py -> __version__ = "X.Y.Z"
9+
```
10+
11+
Everything else derives from it, so the fields can never drift apart:
12+
13+
- **`setup.py`** reads `_version.py` at build time (via `exec`, without importing
14+
the package), so the wheel/sdist metadata matches.
15+
- **`vfbquery/__init__.py`** does `from ._version import __version__`, so
16+
`vfbquery.__version__` (and `ha_api.py`'s version reporting) matches.
17+
- **The SOLR result cache** stamps entries with this version (major.minor) and
18+
uses it for invalidation — see [CACHING.md](CACHING.md#cache-versioning-and-invalidation).
19+
20+
Do **not** hard-code the version anywhere else.
21+
22+
## Cutting a release
23+
24+
1. Create a **GitHub Release** with a tag of the form `vX.Y.Z` (e.g. `v1.21.0`).
25+
26+
That's it — the `Publish 🐍 📦 to PyPI` workflow
27+
(`.github/workflows/publish_to_pypi.yaml`) does the rest:
28+
29+
1. Checks out the tag, extracts `X.Y.Z` from `refs/tags/vX.Y.Z`, and writes it
30+
into `_version.py` (`sed`).
31+
2. Builds the sdist/wheel (version comes from `_version.py`) and verifies the
32+
metadata matches the tag.
33+
3. **Publishes to PyPI** via trusted publishing.
34+
4. **Commits the bump back to `main`** — switches from the detached tag checkout
35+
to live `main`, re-applies `X.Y.Z` to `_version.py`, and pushes
36+
`Bump version to X.Y.Z [skip ci]`.
37+
38+
So after a release, **`main` reflects the released version** too — you don't have
39+
to bump it by hand.
40+
41+
## Cache warming after a release
42+
43+
A minor/major bump invalidates the previous version's cache entries
44+
(see [CACHING.md](CACHING.md#cache-versioning-and-invalidation)), so they're
45+
refilled with the new version's output. That happens two ways, with no dedicated
46+
release-triggered step:
47+
48+
- **Lazily**, by the deployed production service as it serves traffic (the
49+
primary path — each query refreshes on first read).
50+
- **By the `performance-test` workflow on `main`** — its perf steps are writable
51+
on push-to-`main` and scheduled (daily) runs (read-only only on PRs), so they
52+
recompute and re-cache the perf-test query set under the current `main`
53+
version. The daily schedule guarantees the new version's entries are warmed
54+
within a day of a release, so later PR runs read a warm cache.
55+
56+
### Notes & guarantees
57+
58+
- The commit-back step runs **only after a successful publish** and only for
59+
`refs/tags/v*` (`if: success() && startsWith(github.ref, 'refs/tags/v')`).
60+
- It's a **no-op if `main` is already at that version** (guarded by
61+
`git diff --staged --quiet`), so you can also bump `_version.py` in a PR before
62+
tagging and the workflow won't create an empty commit.
63+
- The push needs `contents: write`, which is declared in the workflow's job
64+
`permissions` alongside the `id-token: write` used for PyPI.
65+
- `[skip ci]` keeps the housekeeping commit from retriggering the test/perf
66+
workflows.
67+
68+
### Choosing the version bump
69+
70+
Because the cache namespace is keyed on **major.minor**
71+
(see [CACHING.md](CACHING.md#cache-versioning-and-invalidation)):
72+
73+
- Bump the **patch** for changes that don't alter query *output* — cached results
74+
stay valid (no invalidation).
75+
- Bump **minor/major** when query output changes — older cache entries are then
76+
invalidated on read, so users get refreshed results.

setup.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@
33

44
here = path.abspath(path.dirname(__file__))
55

6-
__version__ = "1.20.0"
6+
# Single source of truth: read __version__ from src/vfbquery/_version.py without
7+
# importing the package (which would pull in runtime dependencies at build time).
8+
_version_ns = {}
9+
with open(path.join(here, "src", "vfbquery", "_version.py")) as _vf:
10+
exec(_vf.read(), _version_ns)
11+
__version__ = _version_ns["__version__"]
712

813
# Get the long description from the README file
914
with open(path.join(here, 'README.md')) as f:

src/vfbquery/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,5 +98,5 @@ def clear_solr_cache(query_type: str, term_id: str) -> bool:
9898
except ImportError:
9999
__solr_caching_available__ = False
100100

101-
# Version information
102-
__version__ = "1.12.1"
101+
# Version information (single source of truth — see _version.py)
102+
from ._version import __version__

src/vfbquery/_version.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Single source of truth for the VFBquery package version.
2+
3+
Both ``setup.py`` (read at build time) and ``vfbquery.__init__`` (imported at
4+
runtime) take ``__version__`` from here, and the release workflow bumps only
5+
this file, so the packaging metadata, ``vfbquery.__version__`` and the SOLR
6+
cache's version stamp can never drift apart.
7+
"""
8+
9+
__version__ = "1.20.0"

0 commit comments

Comments
 (0)