Skip to content

Fetch public keys asynchronously and remove the waiting forms - #8

Open
jwrosewell wants to merge 9 commits into
mainfrom
feature/async-only-fetch
Open

Fetch public keys asynchronously and remove the waiting forms#8
jwrosewell wants to merge 9 commits into
mainfrom
feature/async-only-fetch

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

What changed

The creator's public key is now fetched asynchronously and only asynchronously. Every network method on PublicKeyFetch returns a CompletableFuture and the waiting forms are removed outright. This is one of a set of changes making every OWID port and every 51Did client asynchronous where the network is involved, so the libraries are consistent across languages.

The build stays at Java 8, which has no non-blocking HTTP client in the standard library, so the transport is an interface:

public interface PublicKeyTransport {
    CompletableFuture<String> fetch(String url, String domain);
}

with HttpUrlConnectionTransport as the default. That default runs the existing HttpURLConnection code, redirect refusal and timeouts unchanged, on an Executor the caller may supply, with a bounded pool of daemon threads by default. Its Javadoc says plainly that this is blocking I/O on a background thread and that on Java 11 and later a transport over java.net.http.HttpClient.sendAsync can be supplied instead. The redirect and exact URL rules are written into the interface's Javadoc so a replacement transport keeps them.

Verification with a key already in hand is unchanged and stays synchronous, as do publicKeyUrl and clearCache, which make no request.

Removed

  • public static String publicKeyPem(Owid owid, String scheme) throws OwidException
  • public static OwidVerificationResult verify(Owid owid, String scheme, List<Owid> others)
  • the package-private verifyAtUrl and publicKeyPemAtUrl that only tests used

New

  • public static CompletableFuture<String> publicKeyPem(Owid owid, String scheme) and an overload taking a PublicKeyTransport
  • public static CompletableFuture<OwidVerificationResult> verify(Owid owid, String scheme, List<Owid> others) and an overload taking a PublicKeyTransport
  • PublicKeyTransport and HttpUrlConnectionTransport as above

A publicKeyPem future fails with PublicKeyFetchException (status, domain and code carried) or OwidException. A verify future never fails, every route maps to a status as before.

Kept exactly

The redirect refusal from #7 and the date parameter on the key URL. The cache now holds futures, so concurrent requests for one URL share a fetch and a failed fetch is evicted so the next request retries.

Verification

mvn test on JDK 21 with the compiler release at 8 for main and test sources: Tests run: 134, Failures: 0, Errors: 0, Skipped: 0, BUILD SUCCESS (129 before). New tests: two requests in flight for one key make one request, a failing fetch is not held, the request runs on the executor given, a request the executor refuses is key unavailable, a missing transport is refused. The Java 8, 11 and 17 runtimes are exercised by CI's matrix on this pull request; the repository has no coverage tool, so coverage was checked by inspection, every previous test is kept in async form.

Cache keyed on the key's span, added 7 September 2026

The cache was keyed by the whole key url, and the url carries the identifier's date in minutes, so two identifiers signed a minute apart never shared an entry and a hundred identifiers over a hundred minutes made a hundred requests for one key. Keys are now held by creator end point, which is the key url without its date, each against the span of minutes the creator has confirmed it for. A key is in force from the start of its period until the next key starts, so a key the creator answers with at two minutes was in force at every minute between them, and an identifier dated inside a confirmed span is verified without a request. The span is never widened past a minute the creator has answered for, nor across a minute the creator answered with another key for. A date later than now is held against now, because the creator reads it that way, and a request with no date is read as now.

The bound of 1024 keys and the empty and refill are unchanged, as are the shared request between callers arriving together and the refusal to hold a failure. clearCache now also forgets the requests under way, as the Python and Rust ports do. The same change is on every port that caches. Addresses #9.

mvn test 139 of 139, BUILD SUCCESS. Five tests added. Every main source compiles clean on JDK 17 with -Xlint:all,-try,-options -Werror. Delete the heldKeys >= MAXIMUM_CACHED_KEYS block in hold and theCacheIsBounded fails with held 1025 of at most 1024. Replace IN_FLIGHT.get(url) with null and twoRequestsInFlightForOneKeyMakeOneRequest fails with expected 1 but was 2. Both checked.

Clock drift, added later the same day

The first version of this section read a date later than now as now and held the answer against now. A creator whose clock runs ahead signs identifiers dated in this process's future, and just after a rotation such an identifier would have been served the old key from a span confirmed up to now, reading as not matching for as long as the two clocks differ. A minute within fifteen minutes of now, or later, is now neither served from the cache nor held in it, and a request with no date is treated the same way. Live identifiers therefore cost one request per minute per creator, which is what they always cost, and every identifier older than the allowance is served from the spans. The test that read a future date as now is replaced by one that shows a recent minute asked about twice, a future minute and an undated request asked about, and a minute beyond the allowance held after its first request. mvn test 139 of 139 and the lint compile clean after the change.

The answer carries the span, added 7 September 2026

The public key end point now answers with a JSON object carrying the key as publicKeySPKI together with validFrom and validTo, the UTC moments the key came into force and the next key starts. validFrom is null for a creator with one key and no schedule, and validTo is null for the last key in a schedule. The PEM alone as text is not a valid answer, and a client that receives it reports the key as one it cannot read. The answer is checked before it is sent, by the same code a client checks it with, so a store or schedule that would produce an answer a client refuses is a server error at the creator. Endpoints.publicKeyResponseAt and Endpoints.publicKeyAnswer build the answer from a PublicKeySchedule, and PublicKeyResponse reads, writes and checks the body with the JDK alone.

The client holds the key for the whole span the creator stated, so an identifier dated anywhere in it is verified without a request whatever the clock drift, and live identifiers cost one request per key rather than one per minute. A creator that states no span has its key held against the minutes it confirms, with the fifteen minute clock drift allowance kept out of that cache. A signature that does not verify under the key selected, where the identifier is dated within the drift allowance of an edge of the key's span, is checked against the neighbouring key before it is reported as not matching, because a creator's signing machines may not agree with its schedule to the minute.

The stand in creator in the tests answers with what this port's own server side code builds, so every client test runs against the response a creator built on this port sends. A test with thirty two threads verifying one OWID at the same moment shows one request between them.

mvn test 144 of 144 and every main source compiles clean on JDK 17 with -Xlint:all,-try,-options -Werror.

Brought into line with the specification as merged, 7 September 2026

Four commits, each on its own. The neighbouring key is asked for by the minute just beyond the edge of the span the creator stated, the span the neighbour check works from is the one the creator stated so a key with a start and no end has no later edge, a creator that stated no span has no neighbour to try, and where the creator's own statement puts the identifier's date outside the key it answered with and nothing verifies the key is reported as unavailable rather than the signature as not matching. The creator end point is gone, because the specification no longer has one. The answer carries the key as publicKey and its encoding as format, the one format is spki, a request without it receives spki, and any other value is answered 400. A signature covers the OWID's own bytes and nothing else, so signing and verifying over other OWIDs is gone with the chained interop fixtures, because nothing in production signs that way.

The key request also asks for JSON by name in its Accept header, which it did not before. mvn test 146 of 146, and every main source compiles clean on JDK 17 with -Xlint:all,-try,-options -Werror.

Notes

Written with AI assistance and reviewed before merging. The 51Degrees fork and the pipeline.did package follow this merge.

PublicKeyFetch.publicKeyPem and PublicKeyFetch.verify returned only
once the creator had answered, holding whichever thread asked, which
on a request thread or an event loop is a stall of up to fifteen
seconds for each key not yet held. Both now return a CompletableFuture
at once and the waiting forms are gone, with no deprecated twin left
behind.

The request is made by a new PublicKeyTransport interface, and the new
HttpUrlConnectionTransport is the one used where a caller names none.
It runs the JDK's blocking HttpURLConnection on an Executor, either
one the caller gives or a shared pool of daemon threads bounded at
twice the processors available, because Java 8, which the library
still targets, has no non-blocking HTTP client of its own. On Java 11
and later a transport over java.net.http.HttpClient.sendAsync can be
supplied instead. The redirect refusal and the date parameter are kept
exactly as they were.

The cache now holds the future of each fetch rather than the key, so
two requests for the same key made while the first is in flight share
one request, and a fetch that fails is dropped so the next request
asks again.

The tests join the futures, and five are added, covering the shared
in-flight request, a failure not being held, the request running on
the executor given, an executor that refuses, and a missing transport.
The README example and the class list are updated to the new shape.
@jwrosewell

Copy link
Copy Markdown
Contributor Author

This branch breaks the 51Degrees Java consumer, and the cause is one line.

HttpUrlConnectionTransport.java line 143 uses new URL(url), which the JDK deprecated in Java 20. That matters here because pipeline-java does not depend on a built artefact, it compiles the OWID source into its pipeline.did module with -Xlint:all,-try,-options and -Werror (pom.xml line 174), excluding only Endpoints.java and PublicKeyFetch.java (pipeline.did/pom.xml lines 100 and 101, and PublicKeyFetch.java is on that list for this same deprecation). Its CI builds on Java 8, 11, 17 and 21, so the Java 21 leg fails.

Checked by compiling, not by reading

Running the pipeline module's exact compiler settings on JDK 21.0.11 over this branch's src/main/java, excluding the same two files:

com\swancommunity\owid\HttpUrlConnectionTransport.java:143: warning: [deprecation] URL(String) in URL has been deprecated
            URLConnection opened = new URL(url).openConnection();
                                   ^
error: warnings found and -Werror specified
1 error
1 warning

The same command over main compiles clean, so the new file is the cause and not something already there.

The fix that works

Replacing that one line with the URI form and adding import java.net.URI; compiles clean under the same settings:

URLConnection opened = URI.create(url).toURL().openConnection();

URI.toURL() has been there since Java 1.0 and is not deprecated, so it holds the Java 8 floor that Animal Sniffer enforces in the consumer.

One behaviour difference to decide rather than absorb silently. new URL(String) throws MalformedURLException, which is an IOException and is caught at line 189. URI.create throws IllegalArgumentException for a string that is not a URI, which is not, so it would fall through to the catch (Throwable e) at line 108 and be reported with whatever status that path gives rather than as a key that could not be fetched. Either catch IllegalArgumentException beside the IOException, or use new URI(url).toURL() and catch URISyntaxException.

Adding the file to the consumer's exclude list also works, and I checked that too, but it would leave the transport uncompiled in the consumer, which is worse than not having the deprecation.

What is not affected

The rest of this change lands cleanly on the consumers, which I checked rather than assumed.

  • The public signature changes here, publicKeyPem and verify returning CompletableFuture, reach no 51Degrees caller, because pipeline.did excludes PublicKeyFetch.java from compilation and fetches keys itself.
  • The equivalent .NET change (owid-dotnet 15) keeps its public surface and moves only an internal method, and FiftyOne.Did builds with no warnings and passes all 185 tests against that branch.
  • The equivalent Python change (owid-python 6) turns three module functions into coroutines, and no 51Degrees code calls them. owid/__init__.py deliberately does not import the module, public_key_schedule.py does not either, and pipeline-python's 51Did client uses Owid.signature_status, which is a different method on the model.

Notes

Written with AI assistance from a session working on the 51Did packages, and needs human review. Nothing here has been changed.

The URL(String) constructor is deprecated from Java 20, and pipeline-java
compiles this source into its 51Did module with -Xlint:all and -Werror,
excluding only Endpoints and PublicKeyFetch, so the new transport would
have failed that build on the Java 21 leg of its matrix.

URI then toURL replaces it. URI.toURL has been present since 1.0, so the
Java 8 floor is unaffected. A url that will not parse now raises
URISyntaxException rather than MalformedURLException, so the catch takes
both and a bad url is still reported as the key being unavailable with
the same status as before.
@jwrosewell

Copy link
Copy Markdown
Contributor Author

Fixed in 010b133. The new transport used the URL(String) constructor, deprecated from Java 20, and pipeline-java compiles this source into its 51Did module with -Xlint:all,-try,-options and -Werror, excluding only Endpoints.java and PublicKeyFetch.java. PublicKeyFetch.java is on that exclusion list for this same deprecation, and the new file was not, so the Java 21 leg of the downstream matrix would have failed.

Reproduced before fixing, compiling this branch's src/main/java on JDK 21.0.11 with those flags and those two exclusions:

HttpUrlConnectionTransport.java:143: warning: [deprecation] URL(String) in URL has been deprecated
error: warnings found and -Werror specified

The same command passes after the change. URI.toURL has existed since 1.0, so the Java 8 floor Animal Sniffer enforces downstream is unaffected.

On the behaviour point, I used new URI(url).toURL() with a multi-catch rather than URI.create. URI.create throws IllegalArgumentException, which is unchecked and would have fallen through to the catch (Throwable) in the executor and been reported differently. new URI(url) throws the checked URISyntaxException, so catch (IOException | URISyntaxException e) keeps a url that will not parse on exactly the path it took before, reported as the key being unavailable with the same status.

mvn test after the change: Tests run: 134, Failures: 0, Errors: 0, Skipped: 0, BUILD SUCCESS.

@jwrosewell

Copy link
Copy Markdown
Contributor Author

Verification run book for this change and the four alongside it: SWAN-community/owid-dotnet#17

It gives the commands and expected results for every port, the traps that waste time (Rust threads its tests where the other six do not, a restored file can leave a build stale and passing against an old binary, Python needs the owid submodule on PYTHONPATH with a Windows separator), and two deliberate breaks that must make specific tests fail, so the tests are shown to be worth something rather than assumed to be.

The cache was keyed by the whole key url, and the url carries the date of
the identifier being verified in minutes. A creator's key changes on the
order of a week, so two identifiers signed a minute apart never shared an
entry and a hundred identifiers over a hundred minutes made a hundred
requests for one key. The cache only ever served a repeat verification of
one identifier.

Keys are now held by end point, which is the key url without its date, and
each key carries the span of minutes the creator has confirmed it for. A key
is in force from the start of its period until the next key starts, so a key
the creator answers with at two minutes was in force at every minute between
them. An identifier dated inside a confirmed span is verified without a
request. One dated outside every span is asked about, and the answer widens
the span when the same key comes back or adds a key when it does not. The
span is never widened across a minute the creator has answered with another
key for.

A date later than now is held against now, because that is how a creator
reads it. Held against the future minute, the key would still be served for
that minute after the creator had rotated. A request without a date is read
as now for the same reason.

The bound of 1024 keys and the empty and refill are unchanged, as is the
sharing of one request between callers arriving together and the refusal
to hold a failure. clearCache now also forgets the requests under way, as
the Python and Rust ports do. The same change is made to every port that
caches, so the ports behave the same way.

Tests cover a minute between two confirmed minutes being served without a
request, a hundred identifiers inside one confirmed period making none, a
key never being served outside its span across a rotation, a future date
being read as now, and the bound against a creator that answers every
minute with a different key. The bound test and the shared request test
were checked by breaking the code and watching them fail. The main sources
compile clean with -Xlint:all,-try,-options -Werror.
…g them

The span cache read a date later than now as now and held the answer
against now. A creator whose clock runs ahead of this one's signs
identifiers dated in this process's future, and just after a rotation the
cache would have served such an identifier the old key from a span
confirmed up to now. It would have read as not matching until this clock
caught up, for as long as the two clocks differ. The cache keyed by url did
not have this fault, because it asked the creator for the exact minute.

A minute within fifteen minutes of now, or later, is now neither served
from the cache nor held in it. The creator may have read such a minute as
its present rather than as the minute named, so its answer says nothing
certain about the minute. A request with no date is treated the same way.
Live identifiers therefore cost one request per minute per creator, which
is what they always cost, and every identifier older than the allowance is
served from the spans. The same allowance is applied on every port that
caches.

The test that read a future date as now is replaced by one that shows a
recent minute asked about twice, a future minute and an undated request
asked about, and a minute beyond the allowance held after its first
request.
The public key end point now answers with a JSON object carrying the key as
publicKeySPKI together with validFrom and validTo, the UTC moments the key
came into force and the next key starts. validFrom is null for a creator
with one key and no schedule, and validTo is null for the last key in a
schedule. The PEM alone as text is not a valid answer, and a client that
receives it reports the key as one it cannot read.

The answer is checked before it is sent, by the same code a client checks
it with. The key must be a public key this library can read, a key valid to
a moment must be valid from an earlier one, and the key must have been in
force at the moment asked about. A creator whose store or schedule fails
that check answers with a server error rather than a bad answer, so a fault
on the server side shows up in the server's own tests and never reaches a
client.

The client holds the key for the whole span the creator stated, so an
identifier dated anywhere in it is verified without a request whatever the
clock drift, and live identifiers cost one request per key rather than one
per minute. A creator that states no span has its key held against the
minutes it confirms, with the fifteen minute clock drift allowance kept out
of that cache as before.

A signature that does not verify under the key selected for the
identifier's minute, where that minute is within the drift allowance of an
edge of the key's span, is checked against the neighbouring key before it
is reported as not matching, because a creator's signing machines may not
agree with its schedule to the minute. Where the key tried was never in
force at the identifier's minute the neighbours are not tried.

Callers verifying the same identifier at the same moment make one request
between them, and a test with many threads proves it.

The stand in creator in the tests answers with what this library's own
server side code builds, so every client test runs against the response a
creator built on this library sends, and the loop between the two halves is
closed.
…d the stated span

The neighbouring key is asked for by the minute just beyond the edge of
the span the creator stated, rather than by a minute a fixed distance from
the identifier, so a key in force for less than the drift allowance is
still the one tried. The span a caller sees is the one the creator stated.
A key stated with a start and no end has no later edge, whatever the cache
holds it for, so a live identifier dated just after a rotation is checked
against the key before it. A creator that stated no span has one key and
no neighbour to try.

Where the creator's own statement puts the identifier's date outside the
span of the key it answered with and nothing verifies, the key is reported
as unavailable rather than the signature as not matching, because a key
that was not in force proves nothing about the identifier.

The request asks for the JSON form by name, and the README and the end
point javadoc describe the answer as the JSON object the specification
requires rather than the PEM alone.
The specification no longer has a creator end point. It repeated the key
the public-key end point serves and added fields no verifier read. The
end point helpers serve the public-key end point alone, the JSON writing
that only the creator answer used goes with it, and the tests that
exercised both end points exercise the one that remains.
The public-key answer carries the key as publicKey and the encoding it is
in as format. The one format served is spki, which a request without the
parameter receives, and any other value is answered 400 rather than in an
encoding the caller did not ask for. The answer is checked for its format
before its key and its span, so a creator never sends an encoding it does
not itself read. The client asks for spki by name, reads publicKey, and
refuses an answer that states another format as a key it cannot read.

The stand in creator in the tests honours the format the request asks
for, so the client is exercised against a creator that refuses the way
the specification requires.
A signature covers the OWID's own bytes without the signature field and
nothing else. Signing and verifying over other OWIDs is gone from Creator,
from every verification surface and from the tests, along with the chained
interop fixtures, because nothing in production signs that way and an
undocumented signing input is a liability for anyone implementing from the
specification.
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.

1 participant