Skip to content

feat(python): test the shared client from several threads - #1516

Draft
yujiyokoo wants to merge 3 commits into
feat/python-sdk-parityfrom
feat/python-sdk-concurrency
Draft

yujiyokoo wants to merge 3 commits into
feat/python-sdk-parityfrom
feat/python-sdk-concurrency

Conversation

@yujiyokoo

Copy link
Copy Markdown
Contributor

Summary

The Python SDK's thread and interrupt rules are now tested through the public API. Several threads can use one Cipher at once, and they share one connection to CipherStash. A Ctrl-C during a call waits for the call to finish, and the Cipher still works afterwards. The README now describes these rules for users.

Changes

  • Request counts by endpoint: the ZeroKMS connection now counts each request it sends, by endpoint. ZeroKMS is CipherStash's key service.
    • _engine.requests() returns the counts, for example {"load-keyset": 1, "retrieve-key": 3}.
    • _engine.request_count() is now their sum.
    • A request counts when it is sent. A failed request counts too. The tests of a refused call rely on this.
  • Shared test helpers: tests/_sync_contract.py has the test model, the thread runner, and the interrupt helpers.
    • pytest now puts tests/ on the path. So the deterministic tests can import the module.
    • The gate test for interrupts now uses the same helpers.
  • First call from 8 threads: tests/_first_call.py runs in a new Python process. Its 8 threads start their first encrypt together, behind a threading.Barrier. The live test checks that the process built one client, and loaded the keyset once.
  • Calls from 8 threads: 8 threads each make 100 encrypt calls on one Cipher. One decrypt then opens all 800 rows, and each row must equal its model.
    • This test runs live, in tests/test_concurrency_live.py.
    • It also runs on the deterministic test build, in tests_deterministic/test_concurrency.py. So it runs on every pull request, with no credentials.
  • Ctrl-C during encrypt: tests/_interrupt.py runs in a new Python process.
    • It times a reference call first: the fastest of three. It starts at 5000 values, and doubles them until a call takes 0.4 seconds, up to 80,000.
    • It then sends SIGINT a quarter of the way into one more call.
    • The live test checks that exactly one KeyboardInterrupt arrives, after at least half the reference time. A call cut short would raise at the quarter.
    • The call returns all its rows or none, and the next encrypt works.
  • Two Cipher instances: the existing live test now also checks that the second Cipher adds no keyset load.
  • README: a new section, "Use it from several threads". It covers these points:
    • The calls are synchronous, and each one releases the GIL.
    • A process has one connection. Every Cipher uses it.
    • Make one Cipher for each model, not one for each web request.
    • A Ctrl-C arrives after the call returns.
    • The timeouts: 10 seconds for each ZeroKMS request, and 30 seconds for each access token request.
  • client.rs: one helper, locked(), now recovers a poisoned lock, in place of four copies.

Verification

  • mise run test:cargo: all core and extension tests pass.
  • clippy and cargo fmt --check: no findings, with and without all features.
  • pytest on Python 3.14: 353 passed, 28 skipped. The skips are the live tests and 2 gate-probe tests.
  • mise run test:deterministic: 19 passed, with the new concurrency test.
  • The gate test on a gate-probe build: 2 passed, with the shared helpers.
  • mypy: no findings in the new and changed test files.
  • Both child scripts ran on the deterministic build, through a local wrapper:
    • In the interrupt script, the signal went at 0.17 seconds. The interrupt arrived at 0.66 seconds, at the end of the call.
    • The first-call script read 0 builds. That is correct, because the deterministic build never builds the live client.
  • Python 3.9 was not run for this branch.
  • The live tests have not run. This machine has no CipherStash credentials. The live job on this PR is their first run.
  • pyright reports that it cannot resolve _sync_contract in the deterministic test. pyright does not read pytest's pythonpath setting. pytest itself imports it correctly.

Related

Closes #1394. Refs #1341.

Review notes

  • Start with tests/_sync_contract.py, then the two child scripts.
  • The interrupt test fails, and does not skip, if 80,000 values take less than 0.2 seconds. Then the signal cannot be sent inside the call, so the test would check nothing. A skip would fail mise run test:live anyway. Only a ZeroKMS on the same machine is that fast.
  • Request counts include failed requests. A failed keyset load also fails the call that made it. So a test of the load count fails on that error first, not on the count.
  • The spec says calls have no timeout. The README states the real timeouts, from stack-kms and stack-auth. A setting to change them is not in this PR.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Tip

🎯 Customize your risk assessments

Guide how CodeRabbit assesses merge risk and security architecture risk. Highlight sensitive changes and explain your security boundaries using Merge Risk Instructions and Security Risk Instructions in your review settings. Security architecture risk assessments require the Advanced plan or above.

✅ Automatic approvals, with your rules

Let CodeRabbit approve pull requests once required threads are resolved, pre-merge checks pass, and your risk limits are met. Enable Automatic approvals in review settings, with overrides for teams or file paths.

@changeset-bot

changeset-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: d3de32e

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@yujiyokoo
yujiyokoo changed the base branch from main to feat/python-sdk-parity November 3, 2026 15:31
@yujiyokoo
yujiyokoo added this pull request to stack #1520 November 4, 2026 02:58
@yujiyokoo
yujiyokoo force-pushed the feat/python-sdk-concurrency branch from 2db232e to ceb64e1 Compare November 4, 2026 15:24
yujiyokoo and others added 3 commits November 5, 2026 00:49
The sync contract in #1341 says threads that make their first call at
once build one engine client and load the keyset once. The request
counter could not show that: it counted the keyset load and the key
requests together. The counting connection now also counts the
load-keyset requests that succeed, read by _engine.keyset_loads(). A
failed load caches nothing and is not counted.

New live tests:
- 8 threads start their first encrypt together, in a new process:
  one client is built, and the keyset loads once.
- 8 threads each encrypt 100 models, and one decrypt opens all 800.
- A SIGINT during a 5000-value encrypt, in a new process: one
  KeyboardInterrupt, after the call returns, with all rows or none,
  and the next encrypt works.

The concurrency test also runs on the deterministic build, so it runs
on every pull request with no credentials.

The README now describes the contract: synchronous calls, the GIL
released, one client per process, and Ctrl-C after the call returns.
It also says that requests within one call go out in sequence, that
each request times out after 10 seconds, and that a Cipher is made
once per model, not once per web request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…#1394)

The interrupt test passed a call cut short by the signal. It now
times a reference encrypt first, sends SIGINT a quarter of the way
into a second one, and requires the interrupt after half the
reference. A cut-short call raises at the quarter. A reference under
0.2 seconds fails, since the signal would have no time to land in the
call.

The model, the threads, and the interrupt helpers are now one module,
tests/_sync_contract.py. The live and deterministic concurrency tests,
the two child scripts, and the gate test use it. The child scripts are
files, not strings. The half-second loop that waited for a second
interrupt is gone: the code after the interrupt gives Python many
checks, and a second one fails the process.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The keyset load counter was a second hand-written counter beside the
request counter, with its own match in the connection and its own
function in core, in the extension and in the stub. The connection
now keeps one map from endpoint to count, read by _engine.requests().
request_count() is its sum, and the keyset loads are its
"load-keyset" entry.

A request counts when it is sent, whether or not it succeeds. The
tests of a refused call rely on that: a request sent by mistake fails
with test credentials, and a count of successes would miss it. The
two-Cipher test now checks that the second Cipher adds no load,
so a load retried in an earlier test does not fail it.

The interrupt test now times the fastest of three reference calls,
and doubles the values from 5000 until a call takes 0.4 seconds, up
to 80,000. A fast link gets a longer call, not a failure.

pytest now puts tests/ on the path, so the deterministic test imports
the shared helpers with no path code. The poisoned-lock recovery in
client.rs is one helper, locked(), in place of four copies.

The README now names both timeouts: 10 seconds for each ZeroKMS
request, and 30 seconds for each access token request. It says when
a token request is made: on the first call when no fresh token is
stored, and shortly before the token expires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@yujiyokoo
yujiyokoo force-pushed the feat/python-sdk-concurrency branch from ceb64e1 to d3de32e Compare November 4, 2026 15:49

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

python: concurrency and the sync contract

1 participant