Microsoft Azure: real-cloud results
Results of running the Halyx conformance kits against real Microsoft Azure services in australiaeast (Sydney), acting as
the halyx-test service principal. Every run creates its own throwaway resources and deletes them afterwards. See
Cloud testing for how the runs work.
| Tier | Services | Latest result |
|---|---|---|
| 1 | Blob Storage, Cosmos DB, Service Bus, Key Vault, App Configuration, Event Grid namespaces (Entra ID only); Spring Data repositories (blocking and reactive) on Cosmos DB, and Spring Boot on Cosmos DB, Event Grid and, with halyx-reactor, WebFlux | 220/223 passed, 3 skipped (expected); Spring 22/22 |
| 2 | Azure Database for PostgreSQL and Azure SQL with Entra ID login; R2DBC and Spring Data R2DBC on PostgreSQL | 13/13; Spring 2/2 |
| 3 | Azure Managed Redis with Entra ID (access keys off) | 55/55; Spring 2/2 |
| 4 | Event Hubs through its Kafka endpoint, with Entra ID (shared access keys off), plus Spring Boot 3 and 4 | 27/29 passed, 2 skipped (expected); Spring 2/2 |
Tier 1: latest result#
cloud-testing/scripts/azure-setup-test-principal.sh # once, as a subscription Owner
cloud-testing/scripts/azure-real-cloud.sh # AZURE_LOCATION=australiaeast by default
IT_TEST='RealAzureIT$Cosmos' cloud-testing/scripts/azure-real-cloud.sh # one suite only
Identity. Terraform and the tests act as a dedicated halyx-test service principal. Its
credentials live in ~/.azure/halyx-test.env, outside the repository and readable only by its owner.
The setup script:
- registers the resource providers
- creates the principal with Contributor on the subscription
- gives it Role Based Access Control Administrator with a condition that lets it assign and remove only the data-plane roles the tests use (listed under IAM)
The tests reach every service through DefaultAzureCredential's environment-variable path, as a deployed
application would.
Provision. Terraform (cloud-testing/infra/azure/tier1) creates a resource group of the run's own, tagged
halyx-test=<run id>, with:
- a StorageV2 account
- a Standard Service Bus namespace
- a serverless Cosmos DB account with the
itemsanditems-sortedcontainers - a Key Vault in RBAC mode
- a free App Configuration store
- a Standard Event Grid namespace with an
orderstopic and aninboxpull (queue) subscription; azurerm has no resources for namespace topics, so those two go through theazapiprovider - the function kit's function app (the emulator tests' Python app) on a Basic plan's single instance, so the runs it counts in memory hold for every invocation; the tests call the function with its function key. Its host has a storage account of its own, which the azurerm provider requires, reached with the account key: the principal may not give the app's managed identity storage roles. Nothing under test uses that account
The kits log in with Entra ID only: shared keys and local auth are off, except on the function host's own storage
account (see below), and on the Cosmos DB account and the Event Grid namespace, whose keys only the key rotation
tests use. Those tests (RealAzureIT$KeyRotation, and aFunctionKeyFromKeyVaultFollowsARotation in
RealSpringFunctionIT) keep a key in the run's Key Vault and change it there while the client stays open: a function
key read on every call, and the Cosmos DB and Event Grid keys switched from the primary to a wrong key and then to the
secondary. The script passes the keys through the environment. Cache and Kafka password rotation runs on the
emulators only (Valkey, and Kafka 3.9 and 4.2 brokers): the clouds' caches and Kafka endpoints here use Entra ID or
IAM. The principal gets Storage Blob
Data Contributor, Service Bus Data Owner, Key Vault Secrets Officer, App Configuration Data Owner, EventGrid Data
Sender and Receiver, and Cosmos DB's built-in Data Contributor. The script waits until the blob role reaches
the data plane (one attempt this run) and gives the rest 30 seconds more.
Test. RealAzureIT creates Service Bus topics (session-enabled with duplicate detection for the
ordering, deduplication and delay tests), subscriptions with a 5-second lock, and a queue.
Tear down. terraform destroy deletes the resource group and purges the Key Vault and App
Configuration store rather than leaving them soft-deleted. A check then lists resource groups, tagged
resources, and deleted vaults and stores; a failed listing is reported as CHECK FAILED.
| Date | 2026-10-16, 15:53–16:15 UTC (22 min) |
| Run id | r26101702533254 |
| Location | australiaeast |
| Result | BUILD SUCCESS: 183 passed, 0 failed, 2 skipped (expected); Spring Boot repository, event and reactive tests passed on Boot 3 and 4 |
| Teardown | resource group destroyed, Key Vault and App Configuration store purged; check: nothing left |
| Service | Halyx API | Tests | Passed | Skipped | Time |
|---|---|---|---|---|---|
| Blob Storage | BlobStore |
75 | 74 | 1 | 148.9s |
| Cosmos DB | DocumentStore |
23 | 23 | 0 | 19.4s |
| Cosmos DB | Spring Data repositories | 10 | 10 | 0 | 6.6s |
| Cosmos DB | reactive Spring Data repositories | 10 | 10 | 0 | 16.3s |
| Service Bus | Publisher / Subscription |
16 | 16 | 0 | 128.1s |
| Key Vault | ParameterStore |
22 | 22 | 0 | 30.7s |
| App Configuration | ParameterStore |
22 | 21 | 1 | 15.2s |
| Event Grid namespace | EventPublisher / pull Subscription |
7 | 7 | 0 | 5.7s |
| Total | 185 | 183 | 2 | ||
| Cosmos DB, Spring Boot 3.5 | Spring Boot repository test | 1 | 1 | ||
| Cosmos DB, Spring Boot 4 | Spring Boot repository test | 1 | 1 | ||
| Event Grid, Spring Boot 3.5 | Spring Boot event test | 1 | 1 | ||
| Event Grid, Spring Boot 4 | Spring Boot event test | 1 | 1 | ||
| Blob Storage, Cosmos DB and Event Grid, Spring Boot 3.5 | Spring Boot reactive test (WebFlux, reactive repository, events) | 3 | 3 | ||
| Blob Storage, Cosmos DB and Event Grid, Spring Boot 4 | Spring Boot reactive test (WebFlux, reactive repository, events) | 3 | 3 |
Skipped tests, both expected:
- Blob
presignWithoutCapabilityIsRejected. This test only applies to providers that can't presign; Azure can. - App Configuration
earlierVersionsCanBeRead. App Configuration keeps no readable history of a key, so the driver doesn't declareVERSIONS.
Service Bus passes every messaging test, including the ones other providers skip: duplicate detection and delayed delivery.
Key Vault timing. The driver no longer waits for Key Vault's long-running deletes: deletedNamesCanBeReused
took 3.5s, the kit retrying while the name was still being deleted (see the findings).
The repository kit and the Spring Boot repository test run on the items and items-sorted containers,
through halyx-spring-data-document. Cosmos DB needs no index beyond its default indexing.
Per-test results#
Blob Storage — BlobStore: 74/75 passed, 148.9s
| Test | Result | Time (s) |
|---|---|---|
abortedPresignedUploadCreatesNoBlob |
passed | 0.29 |
closingTheStreamInsideWriteDoesNotCommitEarly |
passed | 0.11 |
contentEncodingIsStoredAndBytesAreReturnedUnchanged |
passed | 0.11 |
copyDuplicatesContentAndKeepsSource |
passed | 0.32 |
copyMissingSourceThrowsNotFound |
passed | 0.06 |
copyPreservesHttpHeaders |
passed | 0.30 |
defaultTierIsHot |
passed | 0.11 |
deleteAllRemovesEveryKeyIncludingMissingOnes |
passed | 0.47 |
deleteAllSpansSeveralProviderBatches |
passed | 17.76 |
deleteAllWithNoKeys |
passed | 0.00 |
deleteIfMatchOnMissingBlobFails |
passed | 0.18 |
deleteIfMatchWithCurrentETagDeletes |
passed | 0.16 |
deleteIfMatchWithStaleETagFails |
passed | 0.25 |
deleteMissingBlobIsIdempotent |
passed | 0.05 |
deleteRemovesBlob |
passed | 0.19 |
downloadWritesCompleteFile(Path) |
passed | 4.06 |
emptyBlobRoundTrips |
passed | 0.19 |
failedWriteKeepsExistingBlob |
passed | 5.66 |
failedWriteStoresNothing |
passed | 4.04 |
getMissingBlobThrowsNotFound |
passed | 0.04 |
getReturnsMetadataWithContent |
passed | 0.11 |
getWithStaleETagThrowsPreconditionFailed |
passed | 0.23 |
headMissingBlobReturnsEmpty |
passed | 0.12 |
headReturnsSizeAndLastModified |
passed | 0.11 |
ifMatchWithCurrentETagSucceeds |
passed | 0.17 |
ifMatchWithStaleETagThrowsPreconditionFailed |
passed | 0.22 |
ifNotExistsCreatesMissingBlob |
passed | 0.11 |
ifNotExistsRejectsExistingBlob |
passed | 0.18 |
listAllStreamsEveryBlob |
passed | 0.63 |
listEmptyPrefixReturnsNothing |
passed | 0.06 |
listFiltersByPrefix |
passed | 0.26 |
listPaginatesInKeyOrder |
passed | 1.18 |
listSummariesCarrySize |
passed | 0.11 |
listWithDelimiterGroupsKeysIntoPrefixes |
passed | 0.48 |
listWithDelimiterPaginatesOverItemsAndPrefixes |
passed | 0.49 |
listWithoutDelimiterReturnsNoPrefixes |
passed | 0.12 |
openEndedRangeReadsToEnd |
passed | 0.12 |
parallelReadReassemblesRangesInOrder |
passed | 8.41 |
parallelReadValidatesChecksum |
passed | 3.81 |
presignWithoutCapabilityIsRejected |
skipped | 0.00 |
presignedDownloadUrlDownloadsBlob |
passed | 0.30 |
presignedMultipartUploadAssemblesPartsInOrder |
passed | 18.39 |
presignedUploadUrlReplacesExistingBlob |
passed | 0.33 |
presignedUploadUrlUploadsBlob |
passed | 0.31 |
presignedUploadWithContentTypeSetsContentType |
passed | 0.59 |
presignedUrlReportsExpiry |
passed | 0.05 |
putFromFile(Path) |
passed | 0.12 |
putFromStream |
passed | 0.11 |
putLargeFile(Path) |
passed | 12.86 |
putOverwritesExistingBlob |
passed | 0.21 |
putReturnsETagMatchingHead |
passed | 0.10 |
putSmallUnknownLengthStream |
passed | 0.13 |
putStoresContentTypeAndUserMetadata |
passed | 0.12 |
putStoresStandardHttpHeaders |
passed | 0.12 |
putThenGetRoundTripsContent |
passed | 0.11 |
putUnknownLengthStream |
passed | 8.83 |
putWithTierStoresTier |
passed | 0.20 |
rangeReadReturnsSlice |
passed | 0.13 |
setTierChangesTierButNotContent |
passed | 0.33 |
setTierToCurrentTierIsANoOp |
passed | 0.34 |
storedChecksumsMatchTheContent |
passed | 0.13 |
unwrapUnknownTypeThrows |
passed | 0.00 |
updateMetadataKeepsTier |
passed | 0.43 |
updateMetadataOfMissingBlobThrowsNotFound |
passed | 0.05 |
updateMetadataReplacesHeadersAndMetadataButNotContent |
passed | 0.46 |
updateMetadataWithStaleETagFails |
passed | 0.19 |
validatedFileAndStreamUploads(Path) |
passed | 0.69 |
validatedLargeFileUploadRoundTrips(Path) |
passed | 12.30 |
validatedPutStoresAChecksumThatValidatesOnDownload |
passed | 0.18 |
validatedWriteInParts |
passed | 9.54 |
writeEmptyBlob |
passed | 0.12 |
writeExactMultipleOfPartSize |
passed | 10.91 |
writeInPartsWithIfNotExistsRejectsExistingBlob |
passed | 10.42 |
writeLargeBlobInParts |
passed | 8.35 |
writeSmallBlobInOneRequest |
passed | 0.19 |
Cosmos DB — DocumentStore: 23/23 passed, 19.4s
| Test | Result | Time (s) |
|---|---|---|
checksGuardATransactionWithoutWriting |
passed | 0.43 |
concurrentIncrementsAreNotLost |
passed | 2.24 |
decimalsRoundTripAsNumbers |
passed | 0.25 |
deleteReportsWhetherAnItemWasRemoved |
passed | 0.49 |
expiredItemsAreNeverReturned |
passed | 3.82 |
failedConditionRollsBackTheWholeTransaction |
passed | 0.67 |
getAllReturnsWhatExistsAcrossBatches |
passed | 0.83 |
ifNotExistsCreatesOnlyOnce |
passed | 0.32 |
indexQueryFindsItemsAcrossPartitionsInSortOrder |
passed | 0.99 |
indexQueryPagesThroughManyMatches |
passed | 1.41 |
indexWithoutSortAttributeFindsEveryMatch |
passed | 0.31 |
invalidKeysAndReservedFieldsAreRejected |
passed | 0.00 |
keysWithSpecialCharactersRoundTrip |
passed | 0.70 |
missingItemIsEmpty |
passed | 0.21 |
putAndGetRoundTripsEveryValueType |
passed | 0.25 |
putReplacesTheWholeItem |
passed | 0.23 |
queryPagesThroughLargePartitions |
passed | 3.44 |
queryReturnsAPartitionInSortOrder |
passed | 1.05 |
transactionAppliesEveryWriteTogether |
passed | 0.61 |
transactionsStayWithinOnePartition |
passed | 0.00 |
updateNeedsAnExistingItemAndTheRightVersion |
passed | 0.51 |
updateSetsRemovesAndIncrements |
passed | 0.19 |
versionsGuardConcurrentWrites |
passed | 0.50 |
Cosmos DB — Spring Data repositories: 10/10 passed, 6.6s
| Test | Result | Time (s) |
|---|---|---|
indexQueriesAcrossPartitions |
passed | 0.87 |
indexWithoutASortAttribute |
passed | 0.37 |
partitionQueriesInSortKeyOrderEitherWay |
passed | 0.82 |
savedEntitiesReadBackWithTheirVersion |
passed | 0.24 |
sortKeyConditions |
passed | 1.19 |
staleVersionsAndTakenIdsAreRejected |
passed | 1.01 |
streamsReadLazily |
passed | 0.28 |
unversionedEntitiesAreSavedInBatchesAndOverwritten |
passed | 0.72 |
uuidKeysAndStringIds |
passed | 0.31 |
windowsScrollThroughAPartition |
passed | 0.82 |
Cosmos DB — reactive Spring Data repositories: 10/10 passed, 16.3s
| Test | Result | Time (s) |
|---|---|---|
fluxQueriesStreamAndCanStopEarly |
passed | 0.40 |
indexQueriesAcrossPartitions |
passed | 1.04 |
indexWithoutASortAttribute |
passed | 0.43 |
partitionQueriesInSortKeyOrderEitherWay |
passed | 1.00 |
savedEntitiesReadBackWithTheirVersion |
passed | 0.46 |
sortKeyConditions |
passed | 1.20 |
staleVersionsAndTakenIdsAreRejected |
passed | 1.15 |
unversionedEntitiesAreSavedInBatchesFromIterablesAndPublishers |
passed | 9.17 |
uuidKeysAndStringIds |
passed | 0.42 |
windowsScrollThroughAPartition |
passed | 0.99 |
Service Bus — Publisher / Subscription: 16/16 passed, 128.1s
| Test | Result | Time (s) |
|---|---|---|
ackedMessageIsNotRedelivered |
passed | 9.71 |
binaryBodiesArriveUnchanged |
passed | 1.61 |
claimCheckSendsLargeBodiesThroughBlobStorage |
passed | 3.39 |
delayedMessageIsHeldBack |
passed | 5.42 |
extendedLeaseKeepsMessageHidden |
passed | 61.78 |
listenerAcksSuccessesAndRetriesFailures |
passed | 12.19 |
listenerLimitsConcurrency |
passed | 3.50 |
nackedMessageIsRedelivered |
passed | 1.84 |
orderingKeyDeliversEachKeyInOrder |
passed | 4.55 |
publishAllDeliversEveryMessage |
passed | 6.55 |
publishedMessageArrivesWithBodyAndAttributes |
passed | 1.49 |
queuesWorkPointToPoint |
passed | 1.51 |
repeatedDeduplicationIdIsDeliveredOnce |
passed | 4.85 |
reservedAttributesAndUnsupportedControlsAreRejected |
passed | 1.31 |
settlingTwiceIsRejected |
passed | 1.67 |
unsettledMessageReturnsAfterItsLease |
passed | 6.74 |
Key Vault — ParameterStore: 22/22 passed, 30.7s
| Test | Result | Time (s) |
|---|---|---|
cachedReadsAreServedUntilRefresh |
passed | 1.24 |
deleteIsIdempotent |
passed | 1.11 |
deletedNamesCanBeReused |
passed | 4.10 |
earlierVersionsCanBeRead |
passed | 1.34 |
getAllDoesNotMatchPartialSegments |
passed | 1.94 |
getAllReturnsEverythingBelowAPrefix |
passed | 3.13 |
invalidNamesAreRejected |
passed | 0.27 |
jsonFieldsCanBeRead |
passed | 1.20 |
largeValues |
passed | 0.82 |
missingIsEmptyAndRequireFails |
passed | 0.69 |
nativeNamesAreUsedAsTheyAre |
passed | 1.53 |
plainValuesHaveNoFields |
passed | 0.84 |
putReplacesWithANewVersion |
passed | 1.14 |
putThenGet |
passed | 1.03 |
reportsKindAndProvider |
passed | 0.21 |
statisticsCountCacheHits |
passed | 0.92 |
storesAreIsolatedByPrefix |
passed | 1.59 |
typedReads |
passed | 1.02 |
valuesKeepSpecialCharactersAndLines |
passed | 0.76 |
watchReportsAddedUpdatedAndRemovedValues |
passed | 2.87 |
watchedChangesInvalidateTheCache |
passed | 2.00 |
writesInvalidateTheirOwnCache |
passed | 0.96 |
App Configuration — ParameterStore: 21/22 passed, 15.2s
| Test | Result | Time (s) |
|---|---|---|
cachedReadsAreServedUntilRefresh |
passed | 0.68 |
deleteIsIdempotent |
passed | 0.89 |
deletedNamesCanBeReused |
passed | 0.85 |
earlierVersionsCanBeRead |
skipped | 0.00 |
getAllDoesNotMatchPartialSegments |
passed | 0.86 |
getAllReturnsEverythingBelowAPrefix |
passed | 1.03 |
invalidNamesAreRejected |
passed | 0.20 |
jsonFieldsCanBeRead |
passed | 0.75 |
largeValues |
passed | 0.50 |
missingIsEmptyAndRequireFails |
passed | 0.42 |
nativeNamesAreUsedAsTheyAre |
passed | 0.74 |
plainValuesHaveNoFields |
passed | 0.51 |
putReplacesWithANewVersion |
passed | 0.65 |
putThenGet |
passed | 0.62 |
reportsKindAndProvider |
passed | 0.15 |
statisticsCountCacheHits |
passed | 0.54 |
storesAreIsolatedByPrefix |
passed | 1.03 |
typedReads |
passed | 0.51 |
valuesKeepSpecialCharactersAndLines |
passed | 0.52 |
watchReportsAddedUpdatedAndRemovedValues |
passed | 2.08 |
watchedChangesInvalidateTheCache |
passed | 1.05 |
writesInvalidateTheirOwnCache |
passed | 0.63 |
Event Grid namespace — EventPublisher / pull Subscription: 7/7 passed, 5.7s
| Test | Result | Time (s) |
|---|---|---|
aRepublishedEventKeepsItsId |
passed | 0.68 |
eventsWithoutDataArrive |
passed | 0.46 |
listenersReceiveEvents |
passed | 0.75 |
oversizedEventsAreRefusedBeforeSending |
passed | 0.05 |
publishAllDeliversEveryEventAcrossRequests |
passed | 2.26 |
publishedEventArrivesWithEveryAttribute |
passed | 0.98 |
textAndBinaryData |
passed | 0.56 |
Tier 2: latest result#
TIER=2 cloud-testing/scripts/azure-real-cloud.sh
The script first registers Microsoft.DBforPostgreSQL and Microsoft.Sql, as the service principal
through the REST API. Terraform (cloud-testing/infra/azure/tier2) then creates:
- PostgreSQL: a flexible server (16,
B_Standard_B1ms) with Entra and password auth. The service principal is its Entra admin (rolehalyx-test), and a password adminhalyx_admingets a password generated for the run and passed through the environment only. - Azure SQL: a Basic database on a server that allows Entra ID only, with the service principal as its admin. SQL logins are refused.
Both accept only the test machine's address. Teardown deletes the Terraform state, which held the password.
MySQL is not run. An Entra admin on Azure Database for MySQL needs a managed identity that can read the
directory (Graph User.Read.All, GroupMember.Read.All and Application.Read.All, or Directory Readers),
which only a directory admin can grant. Left out by choice for now.
| Date | 2026-10-16, 05:14–05:28 UTC (14 min: PostgreSQL ready in 6m 3s, Azure SQL in under 4 min) |
| Run id | r26101616140f08 |
| Engines | PostgreSQL 16 (flexible server), Azure SQL Database (Basic) |
| Result | BUILD SUCCESS: 13 passed, 0 failed (JDBC 11, R2DBC 2); Spring Boot R2DBC test passed on Boot 3 and 4 |
| Teardown | resource group destroyed; state deleted; check: nothing left |
| Test | PostgreSQL | Azure SQL |
|---|---|---|
loginLatency: runs first; a warm Entra login must take under 3s |
passed (2.3s) | passed (4.6s) |
entraLoginRunsQueriesOverTls: the server reports the session encrypted (pg_stat_ssl / sys.dm_exec_connections.encrypt_option) |
passed (1.1s) | passed (1.0s) |
replacedConnectionsLogInAgainWithTokens |
passed (5.2s) | passed (4.7s) |
entraLoginIsThePrincipalsRole: current_user is halyx-test |
passed (0.9s) | — |
passwordLoginRunsQueriesOverTls: the password admin, over TLS |
passed (0.9s) | — |
tokenDoesNotLogInARoleThatIsNotAnEntraPrincipal: a token for halyx_admin is refused |
passed (6.8s) | — |
wrongPasswordDoesNotLogIn |
passed (3.3s) | — |
sqlPasswordLoginIsRefusedOnAnEntraOnlyServer |
— | passed (2.6s) |
r2dbcEntraLoginRunsQueriesOverTls: R2DBC (halyx-r2dbc) with Entra ID tokens: runs as halyx-test, over TLS |
passed (0.9s) | — |
r2dbcTokenDoesNotLogInARoleThatIsNotAnEntraPrincipal: R2DBC: a token for halyx_admin is refused |
passed (5.0s) | — |
Spring Data R2DBC over halyx.r2dbc-databases with Entra ID, Spring Boot 3.5 and 4 |
passed, passed | — |
On Azure SQL the service principal's session runs as <application id>@<tenant id>
(SUSER_SNAME()). The token is the driver's accessToken property, with no username.
Login latency, from run r26101122362e30, the last one that timed logins:
| Entra login, per new connection | PostgreSQL | Azure SQL |
|---|---|---|
| first on the new server | 4,391 ms | 1,026 ms |
| then | 343–366 ms | 300–316 ms |
Tier 3: latest result#
TIER=3 cloud-testing/scripts/azure-real-cloud.sh
The script registers Microsoft.Cache, then Terraform (cloud-testing/infra/azure/tier3) creates an Azure Managed
Redis cache (Balanced_B0, no high availability) with:
- access keys off, so only Entra ID works
- the
EnterpriseClusterclustering policy: one endpoint, with no redirects to node addresses - encrypted connections on the public endpoint (port 10000)
The service principal gets the cache's default access policy. This subscription doesn't offer classic
Azure Cache for Redis in Australia East, and Managed Redis is its successor. The username is the
principal's object id, which Halyx reads from the token's oid claim.
| Date | 2026-10-11, 11:24–11:40 UTC (16 min: cache ready in 7m 27s, a 60s wait for the access policy, tests 15s) |
| Run id | r2610112224c642 |
| Result | BUILD SUCCESS: 52 passed, 0 failed, on the first run |
| Teardown | resource group destroyed; check: nothing left |
| Test | Result |
|---|---|
| Cache conformance kit, with Entra tokens over TLS (public certificate, full host name checks) | 49/49 passed (11.2s) |
entraTokenReadsAndWrites |
passed |
unauthenticatedConnectionIsRejected |
passed |
forgedTokenIsRejected: an unsigned token is refused |
passed |
Tier 4: latest result#
TIER=4 cloud-testing/scripts/azure-real-cloud.sh
The script registers Microsoft.EventHub, then Terraform (cloud-testing/infra/azure/tier4) creates three Event Hubs
namespaces (Standard, one throughput unit) with shared access keys off, so only Entra ID works, and makes the
principal their Azure Event Hubs Data Owner. RealAzureStreamIT runs the stream kit on the namespaces' Kafka
endpoints (port 9093), logging in with Entra ID tokens through SASL OAUTHBEARER. It creates each test's
topics (event hubs) through Kafka's admin API, logged in the same way, and deletes older ones as it goes.
Before each creation it logs how many topics the namespace lists.
After the kit, RealSpringStreamIT runs on Spring Boot 3.5 and on Spring Boot 4, with the Spring for Apache
Kafka and Kafka clients each manages, against the namespace the kit used least. In one Spring application it
sends and receives records through the starter's StreamProducer bean and @HalyxListener, and through Spring
for Apache Kafka's KafkaTemplate and @KafkaListener, which log in through halyx.spring-kafka.cluster. It
creates its two topics with Kafka's admin client, logged in with kafkaClientProperties.
KAFKA_CLIENTS=4.2.1 TIER=4 cloud-testing/scripts/azure-real-cloud.sh runs the same tests on Kafka 4 clients.
Three namespaces, because a Standard namespace refused new event hubs with PolicyViolationException after
about twenty had been created in a run, even with only one or two alive (see the run history). The test
moves to the next namespace before one has created twelve.
| Date | 2026-10-16, 00:44–00:54 UTC (10 min: a 90s wait for the role, kit 3m 38s, Spring tests 33s) |
| Run id | r2610161144379f |
| Clients | Kafka 3.9.2 (kit and Boot 3, with Spring for Apache Kafka 3.3.16); Kafka 4.2.1 (Boot 4, with Spring for Apache Kafka 4.1.1) |
| Result | all passed: kit 24 passed, 1 skipped; Spring 2 of 2 |
| Teardown | resource group destroyed; check: nothing left |
| Test | Result |
|---|---|
| Stream conformance kit, with Entra ID tokens over TLS | 24/25 passed, 1 skipped (217.7s) |
largeValuesTravelThroughAClaimCheck |
skipped: tier 4 creates no blob store |
idempotentProducersNeedTheCapability |
passed: Event Hubs accepts idempotent producers, which Halyx now declares and leaves on by default |
recordsWithTheSameKeyStayInOrder, handlerSeesEveryRecordOfItsPartitionInOrder |
passed with idempotent producers and Kafka's five requests in flight |
transactionalSendAllIsVisibleToReadCommittedConsumers |
passed: Halyx doesn't declare transactions on Event Hubs Standard, and refuses them with UnsupportedFeatureException |
RealSpringStreamIT on Spring Boot 3.5: starter, KafkaTemplate and @KafkaListener |
passed (15.5s) |
RealSpringStreamIT on Spring Boot 4 |
passed (17.5s) |
The same kit, with idempotence on, also passed 24/25 on Kafka 4.2.1 clients (r2610161121e9c0).
Topics per namespace in the passing run: 11, 11 and 4, with one listed before each creation, so deletes do take effect.
Tier 5: latest result#
TIER=5 AZURE_FEDERATED_GOOGLE_SA=<Google Cloud test service account> cloud-testing/scripts/azure-real-cloud.sh
Terraform (cloud-testing/infra/azure/tier5) creates a storage account with shared keys off, and gives the
principal Storage Blob Data Contributor on it. For the federated login, the script gets a Google ID token for the
Google Cloud test service account, with the audience api://AzureADTokenExchange, which the halyx-test
application trusts through a federated identity credential (set up once). That is code on Google Cloud
logging in to Azure with no stored secret.
RealAzureLoginIT names the principal explicitly:
| Test | Checks | Result |
|---|---|---|
aServicePrincipalLogsInWithItsSecret |
Credentials.servicePrincipal(tenant, client, secret): a blob written and read |
passed |
aWrongSecretIsRefused |
the first call fails with AADSTS7000215, so the explicit credentials are what logs in, not the environment |
passed |
aServicePrincipalLogsInWithAFederatedTokenFromGoogleCloud |
Credentials.servicePrincipalWithFederatedToken(tenant, client, tokenFile) with the Google ID token: a blob written and read |
passed |
aTokenFromAnUntrustedIssuerIsRefused |
a token from an issuer the application doesn't trust is refused | passed |
Then Spring Boot 3.5 and 4, each twice: credentials.type: service-principal with secret (from the
environment), and with token-file. 4/4; Spring 4/4, run r26102311158b75, nothing left after teardown.
Run history#
| Run id | Scope | Result | Teardown | Notes |
|---|---|---|---|---|
r26101120222893 |
tier 1 | 150 passed, 1 failed, 6 errors, 2 skipped | clean (checked by hand) | Cosmos: patch returned no document (NullPointerException), and ids escaped with % couldn't be read back. Blob: InvalidMetadata for the name schema-version. The final check failed outright: the script passed --subscription before the az command. It reported CHECK FAILED instead of "nothing left", and a manual check found nothing. |
r26101120508d84 |
tier 1 | 156/158, 2 skipped | clean | |
r26101121415f6f |
tier 1, Key Vault | 22/22 | clean | Key Vault waits removed from the driver; the kit retries via deleteLag(). deletedNamesCanBeReused took 4.2s. One transient error on a pooled connection to the vault made missingIsEmptyAndRequireFails take 43.8s while the SDK retried (0.6s in the run before). |
r26101122061ed2 |
tier 2 | not run | clean | Creating the PostgreSQL flexible server failed with InternalServerError (no detail) on Azure's side; the subscription isn't restricted (OfferRestricted is disabled, Burstable available). Retried unchanged. |
r2610112224c642 |
tier 3 | 52/52 | clean | Latest tier 3 result, above, on the first run. |
r26101122362e30 |
tier 2 | 11/11 | clean | The same configuration succeeded on retry. |
r2610121824c199 |
tier 4 | not run | clean | Terraform couldn't assign Azure Event Hubs Data Owner: the principal's role-assignment condition allowed only tier 1's four roles. azure-setup-test-principal.sh now allows it, and updates the condition on rerun. |
r26101218310076 |
tier 4 | 18 passed, 2 failed, 4 errors, 1 skipped | clean (the check printed STILL PRESENT for a stray newline; checked by hand, and the scripts now ignore whitespace) |
Records with the same key arrived out of order: [7, 10, …, 58, 1, 4]. The last four topic creations failed with PolicyViolationException. latestStartSkipsEarlierRecords raced the consumer's first fetch. |
r26101218441879 |
tier 4 | 21 passed, 1 failed, 2 errors, 1 skipped | clean | Order fixed. A transactional producer failed in Event Hubs' FindCoordinator instead of with UnsupportedFeatureException. Topic creation still refused after about twenty, with two alive. |
r26101219005a2c |
tier 4 | 22 passed, 2 errors, 1 skipped | clean | Three namespaces; nothing refused, and one topic listed before each creation. The test allowed only eight topics per namespace, two short of the kit's 26. |
r2610121909d4c7 |
tier 4 | 24/25, 1 skipped | clean | Idempotence off on Event Hubs, as Halyx had it until then. |
r2610161043df40 |
tier 4, Kafka 3.9 clients, with an idempotence probe | 24/25, 1 skipped; probe passed | clean | The probe sent through Kafka's own producer with idempotence on: Event Hubs accepted it and stored the records in order. |
r261016105299da |
tier 4, Kafka 4.2.1 clients | 24/25, 1 skipped; probe error | clean (checked by hand) | The kit passed on Kafka 4 clients. The probe's own settings were wrong: Kafka 4's linger.ms defaults to 5, so its delivery.timeout.ms of 60000 was below linger.ms + request.timeout.ms. The check printed STILL PRESENT for two namespaces Azure's resource index still listed after their group was gone; it now looks again for up to two minutes. |
r2610161112d440 |
tier 4 with idempotence on, plus Spring Boot 3 and 4 | kit 24/25, 1 skipped; Spring 0/2 | clean | Idempotence declared and on by default; the ordering tests pass with it. Both Spring tests created their topics through kafkaClientProperties, then failed on a SpEL expression in the test's @KafkaListener. |
r2610161121e9c0 |
tier 4, Kafka 4.2.1 clients, idempotence on | 24/25, 1 skipped | clean | |
r2610161144379f |
tier 4, plus Spring Boot 3 and 4 | 24/25, 1 skipped; Spring 2/2 | clean | Latest tier 4 result, above. |
r26101612477b26 |
tier 1, plus Spring Data repositories and Spring Boot 3 and 4 | 166/168, 2 skipped; Spring 2/2 | clean | Repositories 10/10 on Cosmos DB. |
r261016150465a1 |
tier 2, plus R2DBC and Spring Boot 3 and 4 | 4 passed, 6 errors, 1 failure; Spring 2/2 | clean | Every Entra ID login in halyx-azure's tests failed with IncompatibleClassChangeError: the R2DBC driver brought reactor-netty-core 1.1 next to the Azure SDK's reactor-netty-http 1.2. The Spring tests, on Boot's managed versions, passed. The build now pins both to one version, and the SQL guide says to align them. |
r26101616140f08 |
tier 2, plus R2DBC and Spring Boot 3 and 4 | 13/13; Spring 2/2 | clean | Latest tier 2 result, above. |
r261016202476f2 |
tier 1, plus the event bus kit and Spring Boot 3 and 4 | 171 passed, 1 error, 2 skipped; Spring 4/4 | clean | Event Grid 5/6: textBinaryAndAbsentData failed because Event Grid refuses "data": null, which the Azure SDK writes for an event without data. |
r261016214897d6 |
tier 1, Event Grid only (IT_TEST='RealAzureIT$EventGrid') |
7/7 | clean | After the fix, and with the kit's data test split in two. |
r26101702533254 |
tier 1, plus halyx-reactor and Spring Boot 3 and 4 | 183 passed, 2 skipped; Spring 10/10 | clean | Latest tier 1 result, above. Reactive repositories 10/10 on Cosmos DB; the reactive Spring test (WebFlux over the container, a reactive repository, events through the reactive beans) 3/3 on Boot 3.5 and 4, the latter on Reactor 3.8 with the Azure SDK's Reactor Netty 1.2. |
r26101712284442 |
tier 1, plus Spring Boot 3 and 4 | 185 passed, 2 skipped; Spring 16/16 | clean | Latest tier 1 result, with the new Spring tests: @HalyxListener with a claim check (RealSpringMessagingIT) and spring.config.import with reload, health ping and the store bean's cache (RealSpringParametersIT) on Service Bus, Key Vault and App Configuration. |
r2610171252db43 |
tier 2, plus Spring Boot 3 and 4 | 13/13; Spring 2/2 | clean | Latest tier 2 result. |
r26101713052821 |
tier 3, plus Spring Boot 3 and 4 | 53/53; Spring 2/2 | clean | Latest tier 3 result: the cache kit with ping, then Cache beans with Entra ID, a JSON codec and @Cacheable on Boot 3.5 and 4. |
r2610171320c3b7 |
tier 4, plus Spring Boot 3 and 4 | 24 passed, 1 skipped; Spring 2/2 | clean | Latest tier 4 result. |
r2610172037dfd5 |
tier 1, plus Spring Boot 3 and 4 | 198 passed, 2 skipped; Spring 16/16 | clean | Latest tier 1 result, with the asynchronous views (blob, document, messaging and event kits) on every service, non-blocking where the driver has a path: Blob Storage, Cosmos DB, Service Bus and Event Grid. |
r2610172250c1a2 |
tier 1, plus Spring Boot 3 and 4 | 198 passed, 3 failed, 2 skipped; Spring 16/16 | clean | The network stalled for minutes at a time during the run: a blob range read failed with an I/O error, a Cosmos DB read timed out, and a Service Bus message's extended lease ran out while its test was stalled. Rerun below. |
r2610180340666a |
tier 1, plus Spring Boot 3 and 4 | 201 passed, 2 skipped; Spring 16/16 | clean | Latest tier 1 result, with asynchronous receives and settlement in the messaging kit, non-blocking on Event Grid. |
r26101816546ecc |
tier 1, plus Spring Boot 3 and 4 | 204 passed, 1 failed, 1 error, 1 skipped; Spring 16/16 | clean | The asynchronous parameter store. App Configuration answers a delete of a missing key with 204 and no setting, which the async client's plain delete cannot map (the emulator answers 404): fixed in 7bb6d6e. A Service Bus extended lease ran out while the network stalled. Rerun below. |
r26101819374e4d |
tier 1: App Configuration and Service Bus | 47 passed, 1 skipped | clean | The two failed suites again, with the fix: latest tier 1 result for them. Key Vault (async, deletes on a blocking thread) passed in the run above. |
r2610221657c81f |
tier 1, plus Spring Boot 3 and 4 | 207 passed, 1 skipped; Spring 16/16 | clean | Full regression before 1.0.0, at be792e3. App Configuration and Service Bus passed first time. |
r2610221721344e |
tier 2, plus Spring Boot 3 and 4 | 13/13; Spring 2/2 | clean | Full regression before 1.0.0, at be792e3. |
r261022173496a4 |
tier 3, plus Spring Boot 3 and 4 | 55/55; Spring 2/2 | clean | Full regression before 1.0.0, at be792e3. |
r2610221751dbfa |
tier 4, plus Spring Boot 3 and 4 | 27 passed, 2 skipped; Spring 2/2 | clean | Full regression before 1.0.0, at be792e3. The skips are the claim-check tests, plain and asynchronous: the kit run has no blob store for the claim check. |
r26102222164336 |
tier 1, plus Spring Boot 3 and 4 | 207 passed, 1 skipped; Spring 16/16 | clean | Latest tier 1 result, after the dependency security fixes: Reactor 3.8.7 under the Azure SDK, and Micrometer 1.16.8 under Cosmos DB. |
r26102222414536 |
tier 2, plus Spring Boot 3 and 4 | 13/13; Spring 2/2 | clean | Latest tier 2 result, after the dependency security fixes: R2DBC with Entra ID on Reactor 3.8.7. |
r26102311158b75 |
tier 5, plus Spring Boot 3 and 4 | 4/4; Spring 4/4 | clean | The first tier 5 run: a service principal with a secret, and with a federated Google ID token. |
r26103118516ad7 |
tier 1, functions only (IT_TEST='RealAzureIT$AzureFunctions', SPRING_IT_TEST=RealSpringFunctionIT) |
not run | clean | Stopped at terraform apply: the principal's role-assignment rights don't allow giving the function app's managed identity storage roles. The function host now has a storage account of its own with an account key, the one exception to Entra ID only. |
r26103120092661 |
tier 1, functions only | 10 passed, 2 skipped (expected: no trigger and no header limit on Azure Functions); Spring 4/4 |
clean | First function run: the function kit on a function app's single Basic instance, with its function key. RealSpringFunctionIT 2/2 on Boot 3.5 and 4. |
r2611011856dabc |
tier 1, plus Spring Boot 3 and 4 | 220 passed, 3 skipped; Spring 22/22 | clean | Full regression before 1.1.0, at 40d4101. Tier 1 now includes the function kit on Azure Functions and the key rotation tests: RealAzureIT$KeyRotation 3/3, and aFunctionKeyFromKeyVaultFollowsARotation on Boot 3.5 and 4. The first apply with the Event Grid keys read through azapi listKeys. |
r26110121016f75 |
tier 2, plus Spring Boot 3 and 4 | 13/13; Spring 2/2 | clean | Full regression before 1.1.0, at 40d4101. |
r26110121148fa8 |
tier 3, plus Spring Boot 3 and 4 | 54/55; Spring 2/2 | clean | Full regression before 1.1.0, at 40d4101. largeValue timed out: reading a 1 MiB value back took longer than the 2-second default command timeout. The other 54 tests passed on the same connection. |
r261102005486b4 |
tier 3, plus Spring Boot 3 and 4 | 55/55; Spring 2/2 | clean | Rerun of the regression's tier 3, at 40d4101: every test passed, largeValue included. |
r26110121289a1f |
tier 4, plus Spring Boot 3 and 4 | 27 passed, 2 skipped; Spring 2/2 | clean | Full regression before 1.1.0, at 40d4101. |
r261101214198dc |
tier 5, plus Spring Boot 3 and 4 | 3 passed, 1 skipped; Spring 2/2 | clean | Full regression before 1.1.0, at 40d4101. The federated login was skipped: the run had no AZURE_FEDERATED_GOOGLE_SA, so no Google ID token. |
r26110201115756 |
tier 5, plus Spring Boot 3 and 4 | 4/4; Spring 4/4 | clean | Rerun of the regression's tier 5 with AZURE_FEDERATED_GOOGLE_SA, at 40d4101: the federated login with a Google ID token passed too. |
What real Azure found#
The emulators (Floci-az and Microsoft's Service Bus emulator) missed all of these:
| Finding | Fix |
|---|---|
Real Cosmos returns no document from a write unless asked (contentResponseOnWriteEnabled is off in the Java SDK), so update failed after the patch had already applied |
Patch asks for the document back |
Cosmos ids can't contain / \ ? #, and the gateway URL-decodes ids in point reads, so the driver's % escapes were undone (order%232 was looked up as order#2) |
Ids are escaped with ~ (~23), and % itself is escaped too. Ids written by earlier versions with % escapes would need migrating; there's no release yet |
Azure metadata names must be C# identifiers; schema-version is a 400 InvalidMetadata. Halyx documented "letters, digits and '-'" as portable |
The portable form is now lower-case letters, digits and _, starting with a letter. The Azure driver rejects other names with a clear InvalidArgumentException before sending anything |
Azure SQL's Entra-only servers refuse every SQL login, and an Entra admin can be a service principal, whose session runs as <application id>@<tenant id> |
Nothing to change: SqlAuth.iam() sends the token as accessToken, with no username |
| Key Vault deletes and recoveries are long-running operations, and the driver waited for them (about 2–5s per call) | Removed: delete starts the soft delete and returns. Writing a name that is soft-deleted, or still being deleted or recovered, throws the retryable UnavailableException, and starts recovery if the name was soft-deleted. Applications retry; the kit does so through deleteLag() |
R2DBC drivers depend on reactor-netty-core, the Azure SDK on reactor-netty-http. Maven picked 1.1 of one and 1.2 of the other, and every Entra ID token request failed with IncompatibleClassChangeError |
The build pins both modules to one version. Spring Boot applications get matching versions from Boot; others should import reactor-bom, as the SQL guide says |
Event Hubs' Kafka endpoint found these:
| Finding | Fix |
|---|---|
| Records with the same key arrived out of order. Halyx turned idempotence off on Event Hubs at the time, and Kafka's producer then allows five requests in flight per connection: a batch retried after an error lands behind later ones | Without idempotence, the Kafka driver keeps one request in flight per connection, unless the application sets kafka.max.in.flight.requests.per.connection itself |
A transactional producer on Event Hubs Standard failed in FindCoordinator (no transactions), before Halyx could refuse it: the driver started the transaction when the producer was created, ahead of the capability check |
Transactional producers start on first use, so StreamProducerConfig.transactional(true) without TRANSACTIONS fails with UnsupportedFeatureException |
A Standard namespace refused new event hubs with PolicyViolationException after about twenty had been created, whether two or four were alive. Deleted event hubs no longer appear in the namespace's topic list. The limit isn't documented, so this is an observation from two runs |
Test only: topics are spread over three namespaces. Applications don't create topics through Halyx |
| Halyx turned idempotence off on Event Hubs, where it wasn't confirmed. A Standard namespace accepts Kafka's idempotent producer, and the kit's ordering tests pass with it, on Kafka 3.9 and 4.2 clients | Event Hubs declares IDEMPOTENCE, and producers are idempotent by default, as on MSK and Managed Kafka |
| A consumer starting at the latest offset fixes its position on its first fetch, which on Event Hubs came after its partitions were assigned, so a record sent right after assignment could be skipped | Test only: the kit sends until a record arrives. This is how Kafka behaves everywhere, and the API guide says where a new group starts |
Event Grid refuses an event with "data": null (400, "Event data must be encoded as json string or object"), which the Azure SDK writes for an event without data. Leaving data out, or an empty data_base64, is accepted |
Events without data are sent with an empty data_base64, which reads back as no data |
IAM#
| Who | Role | Scope | Why |
|---|---|---|---|
halyx-test service principal |
Contributor | subscription | create and delete each run's resource group and its resources |
| Role Based Access Control Administrator, with a condition | subscription | assign and remove only Storage Blob Data Contributor, Azure Service Bus Data Owner, Key Vault Secrets Officer, App Configuration Data Owner, Azure Event Hubs Data Owner, EventGrid Data Sender and EventGrid Data Receiver | |
| Storage Blob Data Contributor, Azure Service Bus Data Owner, Key Vault Secrets Officer, App Configuration Data Owner | each run's resources (Terraform) | the data planes the tests use | |
| EventGrid Data Sender, EventGrid Data Receiver | each run's Event Grid namespace (Terraform) | publishing events and receiving them from the pull subscription | |
| Azure Event Hubs Data Owner (tier 4) | each run's Event Hubs namespaces (Terraform) | sending, receiving, and creating and deleting event hubs through the Kafka endpoint | |
| Cosmos DB Built-in Data Contributor (Cosmos RBAC) | each run's Cosmos account (Terraform) | Cosmos' own data-plane roles | |
| Entra admin (tier 2) | each run's PostgreSQL server and Azure SQL server (Terraform) | the database logins under test | |
default access policy (tier 3) |
each run's Managed Redis cache (Terraform) | the cache's data plane |
Tiers 2 and 3 need nothing beyond tier 1's setup: Contributor covers registering their resource providers, creating the servers and caches, and naming the principal as their Entra admin or access-policy holder.
Tier 5 needs nothing beyond tier 1's setup for its secret login. Its federated login needs a federated identity
credential on the halyx-test application, created once, that trusts Google ID tokens for the Google Cloud test
service account:
az ad app federated-credential create --id <application id> --parameters '{
"name": "google-halyx-test",
"issuer": "https://accounts.google.com",
"subject": "<the service account unique id: gcloud iam service-accounts describe <email> --format=value(uniqueId)>",
"audiences": ["api://AzureADTokenExchange"]
}'
The run then gets a token with gcloud auth print-identity-token --impersonate-service-account, for the service
account in AZURE_FEDERATED_GOOGLE_SA, so the person running it needs roles/iam.serviceAccountTokenCreator on
that account, as for the Google Cloud runs. Without AZURE_FEDERATED_GOOGLE_SA, the federated test is skipped.
IAM for the AKS run#
cloud-testing/scripts/aks-real-cloud.sh runs the portable orders service on AKS, and
needs nothing beyond tier 1's setup. Contributor creates the cluster, its registry and a user-assigned managed
identity, and federates the identity to the service's Kubernetes service account. The identity gets the same
data-plane roles the principal may assign: Storage Blob Data Contributor, Azure Service Bus Data Owner, Key Vault
Secrets Officer, App Configuration Data Owner, Azure Event Hubs Data Owner, and EventGrid Data Sender and Receiver.
It also gets Cosmos DB Built-in Data Contributor, the Managed Redis default access policy, and the PostgreSQL
server's Entra admin. The pods pull images with the registry's admin credentials, because AcrPull isn't among the
roles the principal may assign.