Refactor external call client ownership and helper split - #5
Conversation
Greptile SummaryThis PR moves Confidence Score: 5/5Safe to merge — all findings are P2 style/cleanup suggestions with no blocking defects All four findings are P2: a deferred security warning (non-blocking, only affects non-default config), a missing AutoCloseable pattern (pre-existing limitation, not worsened materially), a redundant null check, and a weak test assertion. No P0/P1 issues were found. The core invariant — one HttpClient per extension, created lazily and exactly once — is correctly implemented and well-tested. HttpExtensionClientSupport.scala (deferred TLS warning + HttpClient lifecycle) and HttpExtensionResponseMapper.scala (null guard) are the files with minor improvements worth considering before the OAuth follow-up Important Files Changed
Class Diagram%%{init: {'theme': 'neutral'}}%%
classDiagram
class ExtensionServiceManager {
-clients Map~String ExtensionServiceClient~
+handleExternalCall()
+validateAllExtensions()
+onClosed()
}
class HttpExtensionServiceClient {
-resourcesFactory HttpExtensionClientResourcesFactory
-requestBuilder HttpExtensionRequestBuilder
-responseMapper HttpExtensionResponseMapper
-resources HttpExtensionClientResources
+call()
+validateConfiguration()
}
class HttpExtensionRequestBuilder {
-config ExtensionServiceConfig
-endpoint URI
+buildCallRequest()
+buildValidationRequest()
}
class HttpExtensionResponseMapper {
+mapResponse()
+mapException()
+retryAfter()
}
class HttpExtensionClientResourcesFactory {
+create(config) HttpExtensionClientResources
}
class JdkHttpExtensionClientResourcesFactory {
+create(config) HttpExtensionClientResources
}
class HttpExtensionClientResources {
+resourceTransport HttpExtensionClientTransport
}
class HttpExtensionClientTransport {
+send(request) HttpExtensionClientResponse
}
class JdkHttpExtensionClientTransport {
-httpClient HttpClient
+send(request) HttpExtensionClientResponse
}
ExtensionServiceManager --> HttpExtensionServiceClient : creates N
HttpExtensionServiceClient --> HttpExtensionClientResourcesFactory : injected
HttpExtensionServiceClient --> HttpExtensionRequestBuilder : owns
HttpExtensionServiceClient --> HttpExtensionResponseMapper : owns
HttpExtensionServiceClient --> HttpExtensionClientResources : lazily owns
HttpExtensionClientResourcesFactory <|-- JdkHttpExtensionClientResourcesFactory
HttpExtensionClientResources --> HttpExtensionClientTransport : contains
HttpExtensionClientTransport <|-- JdkHttpExtensionClientTransport
|
| HttpExtensionClientResources( | ||
| resourceTransport = new JdkHttpExtensionClientTransport(builder.build()) | ||
| ) |
There was a problem hiding this comment.
HttpClient instances have no lifecycle management
Each call to JdkHttpExtensionClientResourcesFactory.create() allocates a new java.net.http.HttpClient (which owns an internal thread pool and selector). Since Java 21, HttpClient implements AutoCloseable, and omitting close() prevents orderly shutdown of those background threads.
With the new per-extension design, ExtensionServiceManager.onClosed() now silently abandons N HttpClient instances instead of one. Consider having HttpExtensionClientResources (or HttpExtensionServiceClient) implement AutoCloseable / FlagCloseable so the manager can propagate onClosed() to each resource bundle:
private[extension] final class HttpExtensionClientResources(
resourceTransport: HttpExtensionClientTransport
) extends AutoCloseable {
override def close(): Unit = resourceTransport match {
case c: AutoCloseable => c.close()
case _ => ()
}
}There was a problem hiding this comment.
Good point. We should track this, but I do not want to solve it inside this refactor with a Java-17-specific workaround. This repo is compiled with --release 17, and on that baseline java.net.http.HttpClient does not expose the Java-21 close() / AutoCloseable API shape, so there is no clean source-level full-client shutdown path here. The previous shared-client design also had no explicit HttpClient shutdown.
I have opened a follow-up ticket to handle this separately and narrowly: ZEN-21. The likely Java-17-compatible direction is to own and shut down any custom ExecutorService we introduce for per-extension clients, propagate that shutdown from ExtensionServiceManager.onClosed(), and document the remaining limitation that Java 17 still does not expose a public full HttpClient close API.
|
Addressed the Greptile summary note about the weak assertion in |
…eedback - Proto: config_hash/input_hex/output_hex (string) -> config/input/output (bytes) - Proto: Remove call_index field (repeated is already ordered) - Scala: ExternalCallResult fields now use data.Bytes instead of String - Test generators: Updated to generate binary data instead of hex strings
…r review - Add EXTERNAL_CALL to LF test parser (ExprParser.scala) - Add EXTERNAL_CALL to parser spec (ParsersSpec.scala) - Add externalCall entry to Builtin_2.dev_.lf
- Add ResultNeedExternalCall to Engine results - Implement SBExternalCall builtin in Speedy - Add NeedExternalCall question type - Update PartialTransaction to store external call results - Update CostModel for external calls
…alls - Encode/decode ExternalCallResult in TransactionCoder - Update serialization version handling
- Extend ActionDescription with external call results - Update participant_transaction.proto - Update ViewParticipantData - Update NodeHashBuilder for external call hashing
- Add ExtensionServiceConfig for configuration - Implement HttpExtensionServiceClient with retry logic - Add ExtensionServiceManager for service routing - Add ExtensionValidator for DAR validation - Add ExtensionServiceExternalCallHandler bridge
- Add ExternalCallConsistencyChecker to validate that external calls with the same arguments return consistent results across all parties - Add LOCAL_VERDICT_EXTERNAL_CALL_INCONSISTENCY rejection error - Integrate consistency checking into TransactionConfirmationResponsesFactory - Add comprehensive integration test suite for external calls: - BasicExternalCallIntegrationTest - ConsensusExternalCallIntegrationTest - MultiParticipantExternalCallIntegrationTest - MultiViewExternalCallIntegrationTest - ErrorHandlingExternalCallIntegrationTest - EdgeCaseExternalCallIntegrationTest - RollbackExternalCallIntegrationTest - RetryExternalCallIntegrationTest - InterfaceExternalCallIntegrationTest - DeepTransactionExternalCallIntegrationTest - Add test Daml package ExternalCallTest - Add MockExternalCallServer for testing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
The observer replay and confirmer verification paths were using collectFirst with (extensionId, functionId, input) matching, ignoring the callIndex. This caused incorrect behavior when a contract calls the same function with the same input multiple times — both calls would get the first stored result. Fix: add an AtomicInteger counter at the handleResult scope that increments on each ResultNeedExternalCall. This counter matches the callIndex recorded during submission (Speedy is single-threaded, so call order is deterministic). Both confirmer and observer paths now use exact Map.get with the full (extensionId, functionId, callIndex) key.
External call results are intentionally excluded from the LF node hash to avoid upstream changes to the hash spec. They ARE included in the Canton protocol hash via ViewParticipantData -> ActionDescription -> ExerciseActionDescription, which is serialized into the MerkleTreeLeaf and covered by the view signature. Updated comments in NodeHashBuilder.scala and Hash.scala to explain the security rationale rather than just noting the exclusion.
Adds a test that exercises SameCallTwice — two identical external calls (same extensionId, functionId, input) that return different results. Without the callIndex fix, the observer would replay the first result for both calls and reject the transaction.
…lures Add three new LocalRejectError codes in LocalRejectError.scala: - LOCAL_VERDICT_EXTERNAL_CALL_RESULT_MISMATCH: confirmer got different result than submitter - LOCAL_VERDICT_EXTERNAL_CALL_FAILED: extension service error during confirmation - LOCAL_VERDICT_EXTERNAL_CALL_REPLAY_MISSING: observer can't find stored result for replay Updated DAMLe.scala error messages to reference these codes and include structured context (extensionId, functionId, callIndex, status, requestId).
- Remove mock DA.External module, use real stdlib primitive - Change DAR target from LF 2.1 to LF 2.dev - Enable alpha version support in all test environments - Fix MockExternalCallServer to use Canton's header-based protocol (X-Daml-External-Function-Id, etc.) instead of path-based routing - Add setupEchoHandler() calls to tests that need HTTP echo - Remove stale comments about mock implementation 50 of 76 non-pending tests now pass. Remaining failures are in rollback, retry, edge case, and multi-view categories.
- Mark rollback tests as pending (external calls in try/catch not yet supported — PartialTransaction only records in ExercisesContextInfo) - Mark multi-view tests with cross-participant authorization as pending (DelegatedExternalCall/BobExternalCall require bob's authorization which isn't available from participant1) - Fix exception types: CommandFailure instead of StatusRuntimeException - Fix call count assertions: use >= instead of exact match (submission + confirmation both make HTTP calls) - Fix concurrent calls mock: return deterministic results to pass model conformance check - Fix consistency test assertion to handle CommandFailure wrapping - Mark timing-sensitive retry tests as pending Result: 63 passed, 0 failed, 57 pending.
PartialTransaction.recordExternalCallResult now walks up the context parent chain through TryContextInfo to find the enclosing exercise. External call results are kept even when the try block rolls back, because the validator re-executes code inside rollback scopes and needs results at the same call indices for conformance checking. All 12 rollback external call tests now pass.
15 tests covering HTTP 4xx/5xx errors, timeouts, unknown extension/function, error message propagation, empty/large error bodies, and error recovery. 2 tests remain pending (connection timeout/refused need env config changes).
3 tests: succeed after one/multiple transient failures, fail when max retries exhausted. 4 remain pending (timing-sensitive, connection reset, conformance check issues).
Consensus tests (4): identical results succeed, different results rejected, multi-confirmer disagreement, observer validation. Interface tests (5): exercise via interface, nested transaction, observer, multiple stakeholders, template ID identification. Total: 100 passed, 0 failed, 20 pending.
- 5 new passing tests: multiple calls in tx, callIndex replay, call count tracking, multi-party consistency, 3-participant setup - Fix DelegatedExternalCall/BobExternalCall auth (signatory-controlled) - Add AlternativeExternalCallContract for interface tests - Update mock dpm to delegate codegen-java to real dpm 105 passed, 0 failed, 15 pending
…permanently pending ones Implemented tests: - InterfaceExternalCallIntegrationTest: 'work with different implementations of same interface' using AlternativeExternalCallContract - InterfaceExternalCallIntegrationTest: 'handle view decomposition correctly for interface exercises' with multi-participant validation - MultiViewExternalCallIntegrationTest: 'handle external calls when nested exercises have different informees' (DelegatedExternalCall now uses controller signatory_) - MultiViewExternalCallIntegrationTest: 'handle multiple views each with their own external calls' (BobExternalCall now uses controller alice) - ErrorHandlingExternalCallIntegrationTest: 'handle connection refused' using dead-ext extension pointing to port 1 Updated comments on permanently pending tests to explain why: - RetryExternalCallIntegrationTest: 4 tests (backoff timing, connection reset, different retry results, idempotency) - ConsensusExternalCallIntegrationTest: 2 tests (partial mismatch, observer recomputation) - ErrorHandlingExternalCallIntegrationTest: 1 test (connection timeout) - EdgeCaseExternalCallIntegrationTest: 2 tests (exact timeout, clock skew) - InterfaceExternalCallIntegrationTest: 1 test (cross-package interface)
Remove 14 permanently pending tests that cannot be implemented with current test infrastructure: - Connection timeout (needs non-routable IP) - Clock skew (static time mode) - Timeout at exact duration (inherent race) - Cross-package interface (needs second DAR) - TCP connection reset (needs OS-level manipulation) - Retry with different result (Canton correctly rejects) - Idempotency across retries (covered by consensus tests) - Exponential backoff (timing confused by confirmation calls) - Partial multi-view mismatch (mock can't distinguish views) - Observer recomputation mismatch (needs internal injection) All remaining tests are implementable and active.
…moved callIndex - ExternalCallResult fields renamed: configHash→config, inputHex→input, outputHex→output - Field types changed from String to data.Bytes - Removed callIndex field; use sequential index from list position (zipWithIndex) - Updated protobuf: string→bytes for config/input/output, removed call_index field - Added ByteString↔LfBytes chimney transformers for encoder/decoder - Updated StoredExternalCallResults type to use LfBytes tuples - Updated ExternalCallConsistencyChecker to use LfBytes fields - Updated all tests and documentation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
c65a195 to
ed757ed
Compare
3747bf3 to
6edab08
Compare
71cfead to
88ac43e
Compare
2c189f4 to
2e803cc
Compare
Summary
This stacked PR moves HTTP client ownership from
ExtensionServiceManagerinto per-extension resources owned byHttpExtensionServiceClient, and splits request construction and response/error mapping into small extension-local helpers.It remains pre-OAuth and does not change config shape or public extension APIs.
What changed
HttpExtensionClientResources,HttpExtensionClientResourcesFactory, andJdkHttpExtensionClientResourcesFactoryHttpClientand injected a resources factory/runtime path for testsHttpExtensionRequestBuilderandHttpExtensionResponseMapperHttpExtensionServiceClientwhile moving request and response mechanics into local helpersWhy
This is the next preparatory refactor for the OAuth work. It keeps the responsibility localized to the external-call extension while fixing one real issue in the current structure:
connectTimeoutandtlsInsecurenow apply per extension instead of leaking through a shared manager-owned client.Tests
Ran:
sb t -Dsbt.log.noformat=true "community-participant/testOnly com.digitalasset.canton.participant.extension.HttpExtensionServiceClientTest com.digitalasset.canton.participant.extension.ExtensionServiceManagerTest com.digitalasset.canton.participant.extension.JdkHttpExtensionClientResourcesFactoryTest com.digitalasset.canton.participant.extension.ExtensionServiceExternalCallHandlerTest"