Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,22 @@ jobs:
echo "tag=$TAG" >> $GITHUB_OUTPUT

# Mirror what publish_to_pypi.yaml does so the docker image and the
# PyPI wheel never disagree about their own version.
- name: Sync setup.py / __init__.py version to tag
# PyPI wheel never disagree about their own version. The version lives in a
# single source file (src/vfbquery/_version.py); setup.py reads it at build
# time and __init__.py imports it, so only _version.py is bumped here.
- name: Sync version to tag
id: version
run: |
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
VERSION=${GITHUB_REF#refs/tags/v}
echo "Tag build detected: syncing __version__ to $VERSION"
sed -i "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" setup.py
sed -i "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" src/vfbquery/__init__.py
sed -i "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" src/vfbquery/_version.py
else
VERSION=$(grep '^__version__' setup.py | sed 's/.*"\(.*\)".*/\1/')
VERSION=$(grep '^__version__' src/vfbquery/_version.py | sed 's/.*"\(.*\)".*/\1/')
echo "Branch / dev build: using committed version $VERSION"
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo " setup.py: $(grep ^__version__ setup.py)"
echo " __init__.py: $(grep ^__version__ src/vfbquery/__init__.py)"
echo " _version.py: $(grep ^__version__ src/vfbquery/_version.py)"

- name: Build test Docker image
run: docker build --no-cache . --file Dockerfile --tag test-image
Expand Down
15 changes: 14 additions & 1 deletion .github/workflows/performance-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ jobs:
# IMPORTANT: the retry OVERWRITES performance_test_output.log so the
# downstream "Fail job on test failures" step grades on the second
# attempt's output only.
env:
# Read-only on PRs so a PR check never writes/purges the shared prod
# cache; writable on push-to-main and scheduled runs so those refresh
# and warm it (e.g. after a minor/major release). See
# solr_caching_readonly().
VFBQUERY_CACHE_READONLY: ${{ github.event_name == 'pull_request' && 'true' || 'false' }}
run: |
set +e
echo "=== Performance test attempt 1/2 (parallel) ==="
Expand All @@ -80,6 +86,9 @@ jobs:
if: always()
env:
VFBQUERY_CACHE_ENABLED: 'true'
# Read-only on PRs (never write/purge the shared prod cache); writable
# on push-to-main and scheduled runs so those refresh/warm it.
VFBQUERY_CACHE_READONLY: ${{ github.event_name == 'pull_request' && 'true' || 'false' }}
MPLBACKEND: 'Agg'
VISPY_GL_LIB: 'osmesa'
VISPY_USE_EGL: '0'
Expand Down Expand Up @@ -107,7 +116,11 @@ jobs:
- name: Run Connectivity Tests
if: always()
env:
VFBQUERY_CACHE_ENABLED: 'true'
# Disable the result cache so the connectivity integration tests
# validate the LIVE query against the database, rather than reading
# (possibly stale) entries from the shared production cache or writing
# this run's results back into it. See solr_caching_disabled().
VFBQUERY_CACHE_ENABLED: 'false'
MPLBACKEND: 'Agg'
VISPY_GL_LIB: 'osmesa'
VISPY_USE_EGL: '0'
Expand Down
52 changes: 38 additions & 14 deletions .github/workflows/publish_to_pypi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ jobs:
runs-on: ubuntu-latest
permissions:
id-token: write # IMPORTANT: this permission is mandatory for trusted publishing
contents: write # allows the post-publish step to commit the version bump back to main
steps:
- uses: actions/checkout@v4
with:
Expand Down Expand Up @@ -43,22 +44,18 @@ jobs:
# Set environment variables for the build
echo "VERSION=$VERSION" >> $GITHUB_ENV

# Update version in setup.py
echo "Updating version in setup.py to $VERSION"
sed -i "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" setup.py

# Update version in package __init__.py
echo "Updating version in src/vfbquery/__init__.py to $VERSION"
sed -i "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" src/vfbquery/__init__.py

echo "Updated setup.py version:"
grep "__version__" setup.py
echo "Updated package version:"
grep "__version__" src/vfbquery/__init__.py
# Single source of truth: bump only _version.py. setup.py reads it at
# build time and vfbquery.__init__ imports it at runtime, so the wheel
# metadata, vfbquery.__version__ and the cache version stamp all follow.
echo "Updating version in src/vfbquery/_version.py to $VERSION"
sed -i "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" src/vfbquery/_version.py

echo "Updated version:"
grep "__version__" src/vfbquery/_version.py
else
# Not running from a tag, show current version
echo "Not running from a tag, using existing version from setup.py"
grep "__version__" setup.py
echo "Not running from a tag, using existing version from _version.py"
grep "__version__" src/vfbquery/_version.py
fi

- name: Build distributions
Expand Down Expand Up @@ -110,6 +107,33 @@ jobs:

- name: Publish distribution 📦 to PyPI
uses: pypa/gh-action-pypi-publish@v1.12.2

- name: Commit version bump back to main
# Runs only after a successful publish from a release tag. The build above
# runs from a detached tag checkout, so here we switch to the live main
# branch, re-apply the released version to the single source file
# (_version.py) and push. [skip ci] keeps this housekeeping commit from
# retriggering the test/perf workflows.
if: success() && startsWith(github.ref, 'refs/tags/v')
run: |
set -e
if [[ -z "$VERSION" ]]; then
echo "VERSION not set; nothing to commit."
exit 0
fi
git config user.email "action@github.com"
git config user.name "GitHub Action"
git fetch origin main
git reset --hard origin/main
sed -i "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" src/vfbquery/_version.py
git add src/vfbquery/_version.py
if git diff --staged --quiet; then
echo "main already at version $VERSION; nothing to commit."
else
git commit -m "Bump version to $VERSION [skip ci]"
git push origin HEAD:main
fi

# - name: Publish package to TestPyPI
# uses: pypa/gh-action-pypi-publish@release/v1
# with:
Expand Down
77 changes: 77 additions & 0 deletions CACHING.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,35 @@ Note: data reads in `vfb_queries.py` (term_info, painted domains, ontology
label lookups, etc.) still go to `solr.virtualflybrain.org` — only the result
*cache* moved. The two are independent.

## Cache versioning and invalidation

Every cache entry is stamped with the VFBquery package version (major.minor) that
wrote it, so results from an old code version aren't served after an upgrade.

The **running** version is resolved (in `solr_result_cache.py`) as:

1. the `VFBQUERY_VERSION` environment variable if set, otherwise
2. the installed package version (`importlib.metadata.version('vfbquery')`),

normalized to **major.minor**. That value comes from the single source of truth,
`src/vfbquery/_version.py` (see [RELEASING.md](RELEASING.md)).

On read, if an entry's stamp differs from the running version, invalidation is
**monotonic** — it only discards entries written by an *older* version:

- **Older (or unversioned) entry** → invalidated, deleted, and recomputed by the
current code.
- **Newer entry** (seen by a stale/older install, or by an older deploy running
alongside a newer one) → treated as a miss but **not deleted**. An older client
must never purge a fresher entry; the previous `!=` check did, which let
downgrades wipe live entries and made concurrent versions thrash each other.

Consequences for the major.minor namespace:

- **Patch bumps** (`1.20.0 → 1.20.3`) share the cache — no invalidation.
- **Minor/major bumps** (`1.20 → 1.21`) invalidate older entries on read, so a
release that changes query output naturally refreshes the cache.

## Runtime Configuration

Control caching behavior:
Expand All @@ -133,6 +162,54 @@ Disable caching globally if needed:
export VFBQUERY_CACHE_ENABLED=false
```

When disabled, the cache layer is **fully bypassed** — every query runs live
against Neo4j/Owlery/Solr with **no read, no write, no version-invalidation, and
no contact with the cache server** (`solr_caching_disabled()` in
`solr_result_cache.py`; mirrored in `vfb_connectivity.query_connectivity`).

This is how the **integration tests** run in CI. The test steps that assert on
query *results* (`test_neuron_neuron_connectivity`, `test_neuron_region_connectivity`,
`test_vfb_connectivity`, the unit tests in `python-test.yml`, and `examples.yml`)
set `VFBQUERY_CACHE_ENABLED=false` so they:

- validate the **live** query for the branch under test, not a (possibly stale)
cached result, and
- never write a PR/branch's output back into the **shared production cache**.

The performance workflow's perf-timing steps keep caching enabled on purpose
(they measure warm-cache latency); only the result-asserting steps disable it.

#### Read-only mode

```bash
export VFBQUERY_CACHE_READONLY=true
```

Read-only mode still **reads** the cache (warm results are served), but
suppresses every **mutation** — no writes, no force-refresh clears, and no
version/expiry purges (`solr_caching_readonly()`, gating `cache_result`,
`clear_cache_entry` and `_clear_expired_cache_document`).

This is used by the **performance-test workflow's perf-timing steps**, but only
on **pull requests** — `VFBQUERY_CACHE_READONLY` is set from
`github.event_name == 'pull_request'`. So:

- **On PRs** the perf steps read warm entries for representative timings but
never write or purge. Combined with `VFBQUERY_CACHE_ENABLED=false` on the
result-asserting steps, **no PR run can modify the production cache**.
- **On push-to-`main` and scheduled runs** those perf steps are *writable*, so
they refresh/warm the cache under the current `main` version.

That post-merge + daily-scheduled warming (plus lazy refresh by production
traffic) is what keeps the cache populated for the version on `main`, including
after a release bumps it. There's no dedicated release-triggered warm.

Caveat: a PR that bumps the **minor/major** version reads cold in read-only mode
(its version's entries don't exist yet — see version invalidation below);
same-version PRs read the already-warm production entries. If you'd rather PR
checks read *and* write a cache without touching production, point them at a
separate collection with `VFBQUERY_SOLR_URL` instead.

## Performance Benefits

VFBquery SOLR caching provides significant performance improvements:
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ result2 = vfb.get_term_info('FBbt_00003748') # 54,000x faster!
similar = vfb.get_similar_neurons('VFB_jrchk00s') # Fast after first run
```

📚 See [CACHING.md](CACHING.md) for cache configuration, the `VFBQUERY_CACHE_ENABLED`
bypass (used by the tests), and version-based invalidation; and
[RELEASING.md](RELEASING.md) for how the single-source version (`_version.py`) is
bumped from the release tag.

To get term info for a term:
get_term_info(ID)

Expand Down
76 changes: 76 additions & 0 deletions RELEASING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Releasing VFBquery

## Version: single source of truth

The package version lives in exactly one place:

```
src/vfbquery/_version.py -> __version__ = "X.Y.Z"
```

Everything else derives from it, so the fields can never drift apart:

- **`setup.py`** reads `_version.py` at build time (via `exec`, without importing
the package), so the wheel/sdist metadata matches.
- **`vfbquery/__init__.py`** does `from ._version import __version__`, so
`vfbquery.__version__` (and `ha_api.py`'s version reporting) matches.
- **The SOLR result cache** stamps entries with this version (major.minor) and
uses it for invalidation — see [CACHING.md](CACHING.md#cache-versioning-and-invalidation).

Do **not** hard-code the version anywhere else.

## Cutting a release

1. Create a **GitHub Release** with a tag of the form `vX.Y.Z` (e.g. `v1.21.0`).

That's it — the `Publish 🐍 📦 to PyPI` workflow
(`.github/workflows/publish_to_pypi.yaml`) does the rest:

1. Checks out the tag, extracts `X.Y.Z` from `refs/tags/vX.Y.Z`, and writes it
into `_version.py` (`sed`).
2. Builds the sdist/wheel (version comes from `_version.py`) and verifies the
metadata matches the tag.
3. **Publishes to PyPI** via trusted publishing.
4. **Commits the bump back to `main`** — switches from the detached tag checkout
to live `main`, re-applies `X.Y.Z` to `_version.py`, and pushes
`Bump version to X.Y.Z [skip ci]`.

So after a release, **`main` reflects the released version** too — you don't have
to bump it by hand.

## Cache warming after a release

A minor/major bump invalidates the previous version's cache entries
(see [CACHING.md](CACHING.md#cache-versioning-and-invalidation)), so they're
refilled with the new version's output. That happens two ways, with no dedicated
release-triggered step:

- **Lazily**, by the deployed production service as it serves traffic (the
primary path — each query refreshes on first read).
- **By the `performance-test` workflow on `main`** — its perf steps are writable
on push-to-`main` and scheduled (daily) runs (read-only only on PRs), so they
recompute and re-cache the perf-test query set under the current `main`
version. The daily schedule guarantees the new version's entries are warmed
within a day of a release, so later PR runs read a warm cache.

### Notes & guarantees

- The commit-back step runs **only after a successful publish** and only for
`refs/tags/v*` (`if: success() && startsWith(github.ref, 'refs/tags/v')`).
- It's a **no-op if `main` is already at that version** (guarded by
`git diff --staged --quiet`), so you can also bump `_version.py` in a PR before
tagging and the workflow won't create an empty commit.
- The push needs `contents: write`, which is declared in the workflow's job
`permissions` alongside the `id-token: write` used for PyPI.
- `[skip ci]` keeps the housekeeping commit from retriggering the test/perf
workflows.

### Choosing the version bump

Because the cache namespace is keyed on **major.minor**
(see [CACHING.md](CACHING.md#cache-versioning-and-invalidation)):

- Bump the **patch** for changes that don't alter query *output* — cached results
stay valid (no invalidation).
- Bump **minor/major** when query output changes — older cache entries are then
invalidated on read, so users get refreshed results.
7 changes: 6 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,12 @@

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

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

# Get the long description from the README file
with open(path.join(here, 'README.md')) as f:
Expand Down
4 changes: 2 additions & 2 deletions src/vfbquery/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,5 +98,5 @@ def clear_solr_cache(query_type: str, term_id: str) -> bool:
except ImportError:
__solr_caching_available__ = False

# Version information
__version__ = "1.12.1"
# Version information (single source of truth — see _version.py)
from ._version import __version__
9 changes: 9 additions & 0 deletions src/vfbquery/_version.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"""Single source of truth for the VFBquery package version.

Both ``setup.py`` (read at build time) and ``vfbquery.__init__`` (imported at
runtime) take ``__version__`` from here, and the release workflow bumps only
this file, so the packaging metadata, ``vfbquery.__version__`` and the SOLR
cache's version stamp can never drift apart.
"""

__version__ = "1.20.0"
Loading
Loading