Skip to content

Refactor external call client ownership and helper split - #5

Open
angelol wants to merge 30 commits into
external-call/06-runtime-integrationfrom
external-call/07-refactoring-2
Open

Refactor external call client ownership and helper split#5
angelol wants to merge 30 commits into
external-call/06-runtime-integrationfrom
external-call/07-refactoring-2

Conversation

@angelol

@angelol angelol commented Mar 27, 2026

Copy link
Copy Markdown

Summary

This stacked PR moves HTTP client ownership from ExtensionServiceManager into per-extension resources owned by HttpExtensionServiceClient, 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

  • added HttpExtensionClientResources, HttpExtensionClientResourcesFactory, and JdkHttpExtensionClientResourcesFactory
  • removed the manager-owned shared JDK HttpClient and injected a resources factory/runtime path for tests
  • added HttpExtensionRequestBuilder and HttpExtensionResponseMapper
  • kept retry orchestration in HttpExtensionServiceClient while moving request and response mechanics into local helpers
  • added unit coverage for per-extension resource creation and JDK resource factory settings

Why

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: connectTimeout and tlsInsecure now 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"

@greptile-apps

greptile-apps Bot commented Mar 27, 2026

Copy link
Copy Markdown

Greptile Summary

This PR moves HttpClient ownership from the single shared instance in ExtensionServiceManager into a per-extension HttpExtensionClientResources bundle, and splits the monolithic HttpExtensionServiceClient into three focused helpers: HttpExtensionRequestBuilder, HttpExtensionResponseMapper, and a new JdkHttpExtensionClientResourcesFactory. The structural refactor is clean and well-motivated — it correctly fixes the bug where a single shared HttpClient caused connectTimeout and tlsInsecure settings to leak across extensions.\n\nKey points:\n- The public constructor of ExtensionServiceManager and HttpExtensionServiceClient is preserved unchanged; the primary constructor is now private[extension], enabling clean test injection without altering the production API.\n- private lazy val resources in HttpExtensionServiceClient ensures resourcesFactory.create() is called exactly once per client instance — correctly verified by the new tests.\n- The insecure-TLS warning is now emitted lazily inside JdkHttpExtensionClientResourcesFactory.create(). When validateExtensionsOnStartup = false, the warning will not appear at startup and is silently deferred to the first actual HTTP call — a subtle behavioral change from the old eager-at-construction warning.\n- HttpClient instances created per extension have no lifecycle management (close() is never called), which matters on Java 21+ where HttpClient implements AutoCloseable. The per-extension design increases the count from 1 to N leaked instances.\n- Two small code-quality items: a redundant null check on a Scala String field in HttpExtensionResponseMapper, and a logically redundant createdResourceIds.distinct assertion in ExtensionServiceManagerTest.

Confidence Score: 5/5

Safe 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

Filename Overview
community/participant/src/main/scala/com/digitalasset/canton/participant/extension/ExtensionServiceManager.scala Removes the manager-owned shared HttpClient; adds a secondary public constructor for backward compatibility and injects resourcesFactory/runtime into the primary private[extension] constructor
community/participant/src/main/scala/com/digitalasset/canton/participant/extension/HttpExtensionClientSupport.scala Introduces HttpExtensionClientResources, HttpExtensionClientResourcesFactory, JdkHttpExtensionClientSettings, and JdkHttpExtensionClientResourcesFactory; the insecure-TLS warning is now emitted lazily on first factory.create() call rather than eagerly at manager construction
community/participant/src/main/scala/com/digitalasset/canton/participant/extension/HttpExtensionRequestBuilder.scala New file extracting request construction (endpoint, headers, JWT) from HttpExtensionServiceClient; no functional changes from moved logic
community/participant/src/main/scala/com/digitalasset/canton/participant/extension/HttpExtensionResponseMapper.scala New file extracting response and exception mapping; contains a redundant null-guard on a Scala String body field
community/participant/src/main/scala/com/digitalasset/canton/participant/extension/HttpExtensionServiceClient.scala Replaces the injected transport with a resourcesFactory; delegates to requestBuilder/responseMapper helpers; lazy val resources ensures one HttpClient per client instance
community/participant/src/test/scala/com/digitalasset/canton/participant/extension/ExtensionServiceManagerTest.scala Adds RecordingResourcesFactory and two new tests confirming per-extension resource isolation and single-creation invariant
community/participant/src/test/scala/com/digitalasset/canton/participant/extension/HttpExtensionServiceClientTest.scala Migrates all existing tests from transport injection to FakeResourcesFactory; adds a new test verifying the lazy val initializes the transport exactly once
community/participant/src/test/scala/com/digitalasset/canton/participant/extension/JdkHttpExtensionClientResourcesFactoryTest.scala New test file for settingsFor — validates per-extension connect timeout and insecureTls flag derivation

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
Loading

Comments Outside Diff (1)

  1. community/participant/src/test/scala/com/digitalasset/canton/participant/extension/ExtensionServiceManagerTest.scala, line 738-739 (link)

    P2 createdResourceIds.distinct assertion is redundant

    createdResourceIds stores incrementing 1-based integers (1, 2, …), so every element is already unique. distinct is a no-op here, making the assertion equivalent to createCalls should have size 2, which is already asserted on the line above.

    To actually verify that each extension receives a different HttpExtensionClientResources instance (the real intent), consider comparing the transport objects or using object identity:

    val transports = resourcesFactory.createCalls.map(c => s"response-for-${c.name}")
    transports.distinct should have size 2

Reviews (1): Last reviewed commit: "Refactor external call client ownership" | Re-trigger Greptile

Comment on lines +111 to +113
HttpExtensionClientResources(
resourceTransport = new JdkHttpExtensionClientTransport(builder.build())
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 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 _ => ()
  }
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@angelol
angelol changed the base branch from external-call/06-refactoring-1 to external-call/06-runtime-integration March 30, 2026 09:31
@angelol

angelol commented Mar 30, 2026

Copy link
Copy Markdown
Author

Addressed the Greptile summary note about the weak assertion in ExtensionServiceManagerTest: the fake resources factory now records the actual created resources and the test asserts that the created transports are distinct, so it checks per-extension isolation rather than a synthetic counter.

@angelol
angelol requested a review from trusch March 30, 2026 12:40
trusch and others added 25 commits April 8, 2026 16:33
…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>
@trusch
trusch force-pushed the external-call/06-runtime-integration branch from c65a195 to ed757ed Compare April 8, 2026 14:51
@angelol
angelol force-pushed the external-call/07-refactoring-2 branch from 3747bf3 to 6edab08 Compare April 10, 2026 05:41
@angelol
angelol force-pushed the external-call/06-runtime-integration branch 2 times, most recently from 71cfead to 88ac43e Compare May 7, 2026 12:34
@angelol
angelol force-pushed the external-call/06-runtime-integration branch 2 times, most recently from 2c189f4 to 2e803cc Compare May 13, 2026 16:22
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.

2 participants