From de9ce628d35aac101a267b7d8248fcc38c2c3040 Mon Sep 17 00:00:00 2001 From: Meriam Lachkar Date: Fri, 5 Jan 2024 12:10:14 +0100 Subject: [PATCH] fix build_canton_3x_with_bazel (#18081) * fix build_canton_3x_with_bazel * Code pull * fixing issues with admin-api new project * fix the way we were passing java_conversions to scalapb plugin * Enable `proto_gen` to correctly generate Java conversions when using the `scalapb` plugin (#18087) * Enable `proto_gen` to correctly generate Java conversions when using the `scalapb` plugin * Remove reference to removed parameter --------- Co-authored-by: Rafael Guglielmetti Co-authored-by: Stefano Baghino <43749967+stefanobaghino-da@users.noreply.github.com> --- bazel_tools/proto.bzl | 13 +- canton-3x/BUILD.bazel | 88 +- .../domain}/v0/sequencer_connection.proto | 10 +- .../domain}/v1/sequencer_connection.proto | 6 +- .../participant}/v0/domain_connectivity.proto | 10 +- ...rise_participant_replication_service.proto | 2 +- .../participant}/v0/inspection_service.proto | 2 +- .../participant}/v0/package_service.proto | 2 +- .../v0/participant_repair_service.proto | 4 +- .../v0/party_name_management.proto | 2 +- .../participant}/v0/ping_pong_service.proto | 2 +- .../participant}/v0/pruning_service.proto | 20 +- .../v0/resource_management_service.proto | 2 +- .../v0/traffic_control_service.proto | 6 +- .../participant}/v0/transfer_service.proto | 14 +- .../v1/participant_repair_service.proto | 2 +- .../canton/admin/pruning}/v0/pruning.proto | 2 +- .../canton/admin/scalapb/package.proto | 15 + .../admin/time}/v0/time_tracker_config.proto | 2 +- .../traffic/v0/member_traffic_status.proto | 2 +- .../version/ProtocolVersionAnnotation.scala | 35 + ...rpriseMediatorAdministrationCommands.scala | 2 +- .../EnterpriseSequencerAdminCommands.scala | 2 +- .../client/commands/LedgerApiV2Commands.scala | 34 +- .../commands/ParticipantAdminCommands.scala | 69 +- .../commands/PruningSchedulerCommands.scala | 4 +- .../client/data/ConsoleApiDataObjects.scala | 2 +- .../api/client/data/DomainParameters.scala | 4 + .../api/client/data/PruningSchedule.scala | 2 +- .../canton/config/CantonCommunityConfig.scala | 11 + .../canton/config/CantonConfig.scala | 71 +- .../config/CommunityConfigValidations.scala | 7 +- .../canton/console/ConsoleEnvironment.scala | 71 +- .../canton/console/ConsoleMacros.scala | 232 ++- .../canton/console/InstanceReference.scala | 429 ++++- .../ParticipantReferencesExtensions.scala | 6 +- .../commands/LedgerApiAdministration.scala | 455 ++++- .../commands/ParticipantAdministration.scala | 9 +- .../ParticipantRepairAdministration.scala | 2 +- .../SequencerNodeAdministration.scala | 157 ++ .../commands/TopologyAdministrationX.scala | 89 +- .../environment/CommunityEnvironment.scala | 54 +- .../canton/environment/Environment.scala | 18 +- .../canton/environment/Nodes.scala | 23 + .../community/app/src/pack/config/README.md | 17 + .../api => config}/jwt/certificate.conf | 0 .../api => config}/jwt/jwks.conf | 0 .../api => config}/jwt/unsafe-hmac256.conf | 0 .../app/src/pack/config/mediator.conf | 23 + .../app/src/pack/config/misc/debug.conf | 8 + .../src/pack/config/misc/dev-protocol.conf | 18 + .../app/src/pack/config/misc/dev.conf | 9 + .../config/misc/low-latency-sequencer.conf | 19 + .../pack/config/monitoring/prometheus.conf | 9 + .../src/pack/config/monitoring/tracing.conf | 6 + .../app/src/pack/config/participant.conf | 73 + .../app/src/pack/config/remote/mediator.conf | 14 + .../src/pack/config/remote/participant.conf | 19 + .../app/src/pack/config/remote/sequencer.conf | 19 + .../app/src/pack/config/sandbox.conf | 38 + .../app/src/pack/config/sequencer.conf | 45 + .../community/app/src/pack/config/shared.conf | 28 + .../storage/h2.conf | 7 +- .../app/src/pack/config/storage/memory.conf | 5 + .../app/src/pack/config/storage/postgres.conf | 47 + .../app/src/pack/config/tls/certs-common.sh | 56 + .../app/src/pack/config/tls/gen-test-certs.sh | 38 + .../src/pack/config/tls/mtls-admin-api.conf | 38 + .../pack/config/tls/tls-cert-location.conf | 2 + .../src/pack/config/tls/tls-ledger-api.conf | 19 + .../src/pack/config/tls/tls-public-api.conf | 14 + .../src/pack/config/utils/postgres/databases | 3 + .../app/src/pack/config/utils/postgres/db.env | 10 + .../app/src/pack/config/utils/postgres/db.sh | 128 ++ .../pack/config/utils/postgres/postgres.conf | 37 + .../pack/examples/02-global-domain/README.md | 33 - .../global-domain-participant.canton | 13 - .../global-domain-participant.conf | 16 - .../03-advanced-configuration/README.md | 136 +- .../api/jwt/leeway-parameters.conf | 8 - .../api/large-in-memory-fan-out.conf | 7 - .../api/large-ledger-api-cache.conf | 8 - .../api/public-admin.conf | 7 - .../03-advanced-configuration/api/public.conf | 11 - .../api/wildcard.conf | 7 - .../nodes/domain1.conf | 18 - .../nodes/participant1.conf | 19 - .../nodes/participant2.conf | 19 - .../nodes/participant3.conf | 19 - .../nodes/participant4.conf | 19 - .../parameters/nonuck.conf | 3 - .../participant-init.canton | 22 - .../remote/domain1.conf | 14 - .../remote/participant1.conf | 14 - .../storage/dbinit.py | 51 - .../storage/memory.conf | 5 - .../storage/postgres.conf | 37 - .../src/pack/examples/06-messaging/README.md | 141 -- .../pack/examples/06-messaging/canton.conf | 22 - .../examples/06-messaging/contact/.gitignore | 2 - .../examples/06-messaging/contact/daml.yaml | 14 - .../06-messaging/contact/daml/Contact.daml | 13 - .../contact/daml/Contact.solution | 35 - .../06-messaging/contact/frontend-config.js | 184 -- .../pack/examples/06-messaging/init.canton | 58 - .../examples/06-messaging/message/.gitignore | 2 - .../examples/06-messaging/message/daml.yaml | 12 - .../06-messaging/message/daml/Message.daml | 58 - .../06-messaging/message/frontend-config.js | 137 -- .../app/src/pack/examples/07-repair/README.md | 19 +- .../src/test/resources/advancedConfDef.env | 6 +- .../resources/distributed-single-domain.conf | 4 + .../large-in-memory-fan-out.conf | 3 + .../large-ledger-api-cache.conf | 4 + .../leeway-parameters.conf | 6 + .../tests/ExampleIntegrationTest.scala | 7 +- ...mplestPingXCommunityIntegrationTest.scala} | 25 +- .../protocol/v0/participant_transfer.proto | 2 +- .../src/main/resources/rewrite-appender.xml | 8 + .../canton/config/CacheConfig.scala | 2 +- .../config/DomainTimeTrackerConfig.scala | 2 +- .../canton/config/ServerConfig.scala | 25 - .../config/TimeProofRequestConfig.scala | 2 +- .../canton/crypto/Encryption.scala | 8 + .../digitalasset/canton/crypto/Signing.scala | 2 + .../lifecycle/FutureUnlessShutdown.scala | 7 + .../logging/pretty/PrettyInstances.scala | 7 + .../digitalasset/canton/protocol/Tags.scala | 25 +- .../sequencing/ApplicationHandlerPekko.scala | 200 +++ .../SequencedEventMonotonicityChecker.scala | 36 +- .../sequencing/SequencerAggregatorPekko.scala | 18 +- .../sequencing/SequencerConnection.scala | 3 +- .../sequencing/SequencerConnections.scala | 2 +- .../client/PeriodicAcknowledgements.scala | 7 +- .../ResilientSequencerSubscriberPekko.scala | 21 +- .../sequencing/client/SequencerClient.scala | 686 ++++++-- .../client/SequencerClientFactory.scala | 10 +- .../SequencerClientSubscriptionError.scala | 6 +- .../SequencerClientTransportFactory.scala | 1 + .../SequencerClientTransportPekko.scala | 1 - .../handlers/StoreSequencedEvent.scala | 73 +- .../canton/time/DomainTimeTracker.scala | 21 +- .../canton/topology/DomainOutboxQueue.scala | 17 +- .../canton/topology/TopologyManagerX.scala | 4 +- ...pologyStateForInititalizationService.scala | 2 - .../digitalasset/canton/util}/BatchN.scala | 2 +- .../canton/util/EitherTUtil.scala | 17 +- .../canton/util/IterableUtil.scala | 0 .../canton/util/OrderedBucketMergeHub.scala | 2 + .../digitalasset/canton/util/PekkoUtil.scala | 85 +- .../canton/util/SingletonTraverse.scala | 22 +- .../digitalasset/canton/util/Thereafter.scala | 4 +- .../canton/util/WithGeneric.scala | 46 + .../version/HasProtocolVersionedWrapper.scala | 2 +- .../canton/version/ProtocolVersion.scala | 61 +- .../src/main/daml/CantonExamples/daml.yaml | 6 +- .../admin/grpc/GrpcPruningScheduler.scala | 2 +- .../domain/SequencerConnectClient.scala | 6 + .../grpc/GrpcSequencerConnectClient.scala | 23 + .../canton/scheduler/Schedule.scala | 2 +- .../topology/QueueBasedDomainOutboxX.scala | 80 +- .../canton/traffic/MemberTrafficStatus.scala | 2 +- .../canton/traffic/TopUpEvent.scala | 2 +- ...equencedEventMonotonicityCheckerTest.scala | 9 +- .../SequencerAggregatorPekkoTest.scala | 2 +- ...esilientSequencerSubscriberPekkoTest.scala | 2 +- .../client/SequencedEventValidatorTest.scala | 3 +- .../client/SequencerClientTest.scala | 1567 +++++++++-------- .../canton/util}/BatchNSpec.scala | 11 +- .../util/OrderedBucketMergeHubTest.scala | 3 +- .../canton/util/PekkoUtilTest.scala | 23 +- .../demo/src/main/daml/ai-analysis/daml.yaml | 8 +- .../demo/src/main/daml/bank/daml.yaml | 8 +- .../demo/src/main/daml/doctor/daml.yaml | 8 +- .../src/main/daml/health-insurance/daml.yaml | 8 +- .../src/main/daml/medical-records/daml.yaml | 8 +- .../domain/src/main/protobuf/buf.yaml | 3 +- .../v0/domain_initialization_service.proto | 4 +- ...rise_mediator_administration_service.proto | 16 +- ...ise_sequencer_administration_service.proto | 16 +- ...erprise_sequencer_connection_service.proto | 6 +- .../v0/mediator_initialization_service.proto | 4 +- .../v0/sequencer_administration_service.proto | 4 +- .../v2/mediator_initialization_service.proto | 4 +- .../DomainNodeSequencerClientFactory.scala | 7 +- .../TopologyManagementInitialization.scala | 3 +- .../ConfirmationResponseProcessor.scala | 2 +- .../canton/domain/mediator/Mediator.scala | 4 +- .../domain/mediator/MediatorNodeCommon.scala | 1 + .../mediator/MediatorReplicaManager.scala | 23 +- .../mediator/MediatorRuntimeFactory.scala | 6 +- .../SequencerRuntimeForSeparateNode.scala | 67 +- .../CommunitySequencerNodeXConfig.scala | 2 +- .../config/SequencerNodeConfigCommon.scala | 16 +- .../DirectSequencerClientTransport.scala | 76 +- .../sequencer/SequencerFactory.scala | 30 + .../DirectSequencerSubscriptionFactory.scala | 1 - ...rpriseSequencerAdministrationService.scala | 2 +- .../GrpcSequencerConnectionService.scala | 10 +- .../topology/DomainTopologyDispatcher.scala | 4 +- .../error/SequencerBaseErrorTest.scala | 20 + .../state/EphemeralStateTest.scala | 51 + .../SequencerStateManagerStoreTest.scala | 803 +++++++++ ...quencerStateManagerStoreTestInMemory.scala | 12 + ...equencerDomainConfigurationStoreTest.scala | 107 ++ ...pcSequencerInitializationServiceTest.scala | 57 + ...erpriseSequencerRateLimitManagerTest.scala | 425 +++++ .../traffic/EventCostCalculatorTest.scala | 48 + .../SequencerMemberRateLimiterTest.scala | 297 ++++ .../store/DbTrafficLimitsStoreTest.scala | 35 + .../store/TrafficLimitsStoreTest.scala | 199 +++ .../TrafficLimitsStoreTestInMemory.scala | 17 + .../TrafficLimitsStoreTestPostgres.scala | 8 + .../store/TrafficLimitsStoreTesttH2.scala | 8 + .../ConsoleEnvironmentTestHelpers.scala | 16 +- .../integration/CommonTestAliases.scala | 10 + .../CommunityConfigTransforms.scala | 23 +- .../CommunityEnvironmentDefinition.scala | 21 +- .../IntegrationTestUtilities.scala | 28 +- .../ApiCommandSubmissionServiceV2.scala | 5 +- .../BatchingParallelIngestionPipe.scala | 3 +- .../platform/store/dao/events/ACSReader.scala | 26 +- .../store/dao/events/ContractLoader.scala | 10 +- .../dao/events/ReassignmentStreamReader.scala | 10 +- .../events/TransactionsFlatStreamReader.scala | 10 +- .../events/TransactionsTreeStreamReader.scala | 10 +- .../StoreBackedCommandExecutorSpec.scala | 4 + .../src/main/daml/benchtool/daml.yaml | 4 +- .../src/main/daml/carbonv1/daml.yaml | 4 +- .../src/main/daml/carbonv2/daml.yaml | 8 +- .../src/main/daml/carbonv3/daml.yaml | 6 +- .../src/main/daml/model/daml.yaml | 4 +- .../main/daml/package_management/daml.yaml | 4 +- .../src/main/daml/semantic/daml.yaml | 4 +- .../ledger-json-api/src/test/daml/daml.yaml | 6 +- .../participant/src/main/daml/daml.yaml | 8 +- .../src/main/daml/ping-pong-vacuum/daml.yaml | 10 +- .../src/main/resources/dar/AdminWorkflows.dar | Bin 375310 -> 0 bytes .../canton/participant/ParticipantNode.scala | 6 +- .../participant/ParticipantNodeCommon.scala | 9 +- .../canton/participant/ParticipantNodeX.scala | 2 +- .../admin/AdminWorkflowServices.scala | 7 +- .../admin/DomainConnectivityService.scala | 1 + .../participant/admin/ResourceLimits.scala | 1 + .../admin/data/ActiveContract.scala | 2 +- .../grpc/GrpcDomainConnectivityService.scala | 2 +- .../admin/grpc/GrpcInspectionService.scala | 8 +- .../admin/grpc/GrpcPackageService.scala | 5 +- .../grpc/GrpcParticipantRepairService.scala | 2 +- .../grpc/GrpcPartyNameManagementService.scala | 4 +- .../admin/grpc/GrpcPingService.scala | 2 +- .../admin/grpc/GrpcPruningService.scala | 6 +- .../grpc/GrpcResourceManagementService.scala | 3 +- .../grpc/GrpcTrafficControlService.scala | 2 +- .../admin/grpc/GrpcTransferService.scala | 10 +- .../inspection/SyncStateInspection.scala | 149 +- .../domain/DomainConnectionConfig.scala | 2 +- .../participant/domain/DomainRegistry.scala | 4 +- .../domain/DomainRegistryHelpers.scala | 2 +- .../domain/grpc/GrpcDomainRegistry.scala | 10 +- ...eStoppableLedgerApiDependentServices.scala | 2 +- .../ledger/api/client/JavaDecodeUtil.scala | 29 +- .../pruning/JournalGarbageCollector.scala | 175 ++ .../participant/pruning/PruneObserver.scala | 117 -- .../ParticipantPruningScheduler.scala | 2 +- .../participant/sync/CantonSyncService.scala | 19 +- .../canton/participant/sync/SyncDomain.scala | 26 +- .../ParticipantTopologyDispatcher.scala | 20 +- .../admin/GrpcTrafficControlServiceTest.scala | 2 +- .../protocol/MessageDispatcherTest.scala | 4 +- .../pruning/JournalGarbageCollectorTest.scala | 93 + 271 files changed, 7681 insertions(+), 2882 deletions(-) rename canton-3x/community/{base/src/main/protobuf/com/digitalasset/canton/domain/api => admin-api/src/main/protobuf/com/digitalasset/canton/admin/domain}/v0/sequencer_connection.proto (64%) rename canton-3x/community/{base/src/main/protobuf/com/digitalasset/canton/domain/api => admin-api/src/main/protobuf/com/digitalasset/canton/admin/domain}/v1/sequencer_connection.proto (76%) rename canton-3x/community/{participant/src/main/protobuf/com/digitalasset/canton/participant/admin => admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant}/v0/domain_connectivity.proto (93%) rename canton-3x/community/{participant/src/main/protobuf/com/digitalasset/canton/participant/admin => admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant}/v0/enterprise_participant_replication_service.proto (86%) rename canton-3x/community/{participant/src/main/protobuf/com/digitalasset/canton/participant/admin => admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant}/v0/inspection_service.proto (98%) rename canton-3x/community/{participant/src/main/protobuf/com/digitalasset/canton/participant/admin => admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant}/v0/package_service.proto (98%) rename canton-3x/community/{participant/src/main/protobuf/com/digitalasset/canton/participant/admin => admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant}/v0/participant_repair_service.proto (96%) rename canton-3x/community/{participant/src/main/protobuf/com/digitalasset/canton/participant/admin => admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant}/v0/party_name_management.proto (93%) rename canton-3x/community/{participant/src/main/protobuf/com/digitalasset/canton/participant/admin => admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant}/v0/ping_pong_service.proto (93%) rename canton-3x/community/{participant/src/main/protobuf/com/digitalasset/canton/participant/admin => admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant}/v0/pruning_service.proto (67%) rename canton-3x/community/{participant/src/main/protobuf/com/digitalasset/canton/participant/admin => admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant}/v0/resource_management_service.proto (94%) rename canton-3x/community/{participant/src/main/protobuf/com/digitalasset/canton/participant/admin => admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant}/v0/traffic_control_service.proto (71%) rename canton-3x/community/{participant/src/main/protobuf/com/digitalasset/canton/participant/admin => admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant}/v0/transfer_service.proto (85%) rename canton-3x/community/{participant/src/main/protobuf/com/digitalasset/canton/participant/admin => admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant}/v1/participant_repair_service.proto (88%) rename canton-3x/community/{base/src/main/protobuf/com/digitalasset/canton/pruning/admin => admin-api/src/main/protobuf/com/digitalasset/canton/admin/pruning}/v0/pruning.proto (97%) create mode 100644 canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/scalapb/package.proto rename canton-3x/community/{base/src/main/protobuf/com/digitalasset/canton/time/admin => admin-api/src/main/protobuf/com/digitalasset/canton/admin/time}/v0/time_tracker_config.proto (94%) rename canton-3x/community/{base/src/main/protobuf/com/digitalasset/canton => admin-api/src/main/protobuf/com/digitalasset/canton/admin}/traffic/v0/member_traffic_status.proto (96%) create mode 100644 canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/version/ProtocolVersionAnnotation.scala create mode 100644 canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/SequencerNodeAdministration.scala create mode 100644 canton-3x/community/app/src/pack/config/README.md rename canton-3x/community/app/src/pack/{examples/03-advanced-configuration/api => config}/jwt/certificate.conf (100%) rename canton-3x/community/app/src/pack/{examples/03-advanced-configuration/api => config}/jwt/jwks.conf (100%) rename canton-3x/community/app/src/pack/{examples/03-advanced-configuration/api => config}/jwt/unsafe-hmac256.conf (100%) create mode 100644 canton-3x/community/app/src/pack/config/mediator.conf create mode 100644 canton-3x/community/app/src/pack/config/misc/debug.conf create mode 100644 canton-3x/community/app/src/pack/config/misc/dev-protocol.conf create mode 100644 canton-3x/community/app/src/pack/config/misc/dev.conf create mode 100644 canton-3x/community/app/src/pack/config/misc/low-latency-sequencer.conf create mode 100644 canton-3x/community/app/src/pack/config/monitoring/prometheus.conf create mode 100644 canton-3x/community/app/src/pack/config/monitoring/tracing.conf create mode 100644 canton-3x/community/app/src/pack/config/participant.conf create mode 100644 canton-3x/community/app/src/pack/config/remote/mediator.conf create mode 100644 canton-3x/community/app/src/pack/config/remote/participant.conf create mode 100644 canton-3x/community/app/src/pack/config/remote/sequencer.conf create mode 100644 canton-3x/community/app/src/pack/config/sandbox.conf create mode 100644 canton-3x/community/app/src/pack/config/sequencer.conf create mode 100644 canton-3x/community/app/src/pack/config/shared.conf rename canton-3x/community/app/src/pack/{examples/03-advanced-configuration => config}/storage/h2.conf (74%) create mode 100644 canton-3x/community/app/src/pack/config/storage/memory.conf create mode 100644 canton-3x/community/app/src/pack/config/storage/postgres.conf create mode 100644 canton-3x/community/app/src/pack/config/tls/certs-common.sh create mode 100755 canton-3x/community/app/src/pack/config/tls/gen-test-certs.sh create mode 100644 canton-3x/community/app/src/pack/config/tls/mtls-admin-api.conf create mode 100644 canton-3x/community/app/src/pack/config/tls/tls-cert-location.conf create mode 100644 canton-3x/community/app/src/pack/config/tls/tls-ledger-api.conf create mode 100644 canton-3x/community/app/src/pack/config/tls/tls-public-api.conf create mode 100644 canton-3x/community/app/src/pack/config/utils/postgres/databases create mode 100644 canton-3x/community/app/src/pack/config/utils/postgres/db.env create mode 100755 canton-3x/community/app/src/pack/config/utils/postgres/db.sh create mode 100644 canton-3x/community/app/src/pack/config/utils/postgres/postgres.conf delete mode 100644 canton-3x/community/app/src/pack/examples/02-global-domain/README.md delete mode 100644 canton-3x/community/app/src/pack/examples/02-global-domain/global-domain-participant.canton delete mode 100644 canton-3x/community/app/src/pack/examples/02-global-domain/global-domain-participant.conf delete mode 100644 canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/jwt/leeway-parameters.conf delete mode 100644 canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/large-in-memory-fan-out.conf delete mode 100644 canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/large-ledger-api-cache.conf delete mode 100644 canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/public-admin.conf delete mode 100644 canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/public.conf delete mode 100644 canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/wildcard.conf delete mode 100644 canton-3x/community/app/src/pack/examples/03-advanced-configuration/nodes/domain1.conf delete mode 100644 canton-3x/community/app/src/pack/examples/03-advanced-configuration/nodes/participant1.conf delete mode 100644 canton-3x/community/app/src/pack/examples/03-advanced-configuration/nodes/participant2.conf delete mode 100644 canton-3x/community/app/src/pack/examples/03-advanced-configuration/nodes/participant3.conf delete mode 100644 canton-3x/community/app/src/pack/examples/03-advanced-configuration/nodes/participant4.conf delete mode 100644 canton-3x/community/app/src/pack/examples/03-advanced-configuration/parameters/nonuck.conf delete mode 100644 canton-3x/community/app/src/pack/examples/03-advanced-configuration/participant-init.canton delete mode 100644 canton-3x/community/app/src/pack/examples/03-advanced-configuration/remote/domain1.conf delete mode 100644 canton-3x/community/app/src/pack/examples/03-advanced-configuration/remote/participant1.conf delete mode 100644 canton-3x/community/app/src/pack/examples/03-advanced-configuration/storage/dbinit.py delete mode 100644 canton-3x/community/app/src/pack/examples/03-advanced-configuration/storage/memory.conf delete mode 100644 canton-3x/community/app/src/pack/examples/03-advanced-configuration/storage/postgres.conf delete mode 100644 canton-3x/community/app/src/pack/examples/06-messaging/README.md delete mode 100644 canton-3x/community/app/src/pack/examples/06-messaging/canton.conf delete mode 100644 canton-3x/community/app/src/pack/examples/06-messaging/contact/.gitignore delete mode 100644 canton-3x/community/app/src/pack/examples/06-messaging/contact/daml.yaml delete mode 100644 canton-3x/community/app/src/pack/examples/06-messaging/contact/daml/Contact.daml delete mode 100644 canton-3x/community/app/src/pack/examples/06-messaging/contact/daml/Contact.solution delete mode 100644 canton-3x/community/app/src/pack/examples/06-messaging/contact/frontend-config.js delete mode 100644 canton-3x/community/app/src/pack/examples/06-messaging/init.canton delete mode 100644 canton-3x/community/app/src/pack/examples/06-messaging/message/.gitignore delete mode 100644 canton-3x/community/app/src/pack/examples/06-messaging/message/daml.yaml delete mode 100644 canton-3x/community/app/src/pack/examples/06-messaging/message/daml/Message.daml delete mode 100644 canton-3x/community/app/src/pack/examples/06-messaging/message/frontend-config.js create mode 100644 canton-3x/community/app/src/test/resources/documentation-snippets/large-in-memory-fan-out.conf create mode 100644 canton-3x/community/app/src/test/resources/documentation-snippets/large-ledger-api-cache.conf create mode 100644 canton-3x/community/app/src/test/resources/documentation-snippets/leeway-parameters.conf rename canton-3x/community/app/src/test/scala/com/digitalasset/canton/integration/tests/{SimplestPingDistributedCommunityIntegrationTest.scala => SimplestPingXCommunityIntegrationTest.scala} (54%) create mode 100644 canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/ApplicationHandlerPekko.scala rename canton-3x/community/{ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/parallel => base/src/main/scala/com/digitalasset/canton/util}/BatchN.scala (97%) rename canton-3x/community/{common => base}/src/main/scala/com/digitalasset/canton/util/IterableUtil.scala (100%) create mode 100644 canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/WithGeneric.scala rename canton-3x/community/{ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/indexer/parallel => common/src/test/scala/com/digitalasset/canton/util}/BatchNSpec.scala (91%) create mode 100644 canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/error/SequencerBaseErrorTest.scala create mode 100644 canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/integrations/state/EphemeralStateTest.scala create mode 100644 canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/integrations/state/SequencerStateManagerStoreTest.scala create mode 100644 canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/integrations/state/SequencerStateManagerStoreTestInMemory.scala create mode 100644 canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/sequencer/store/SequencerDomainConfigurationStoreTest.scala create mode 100644 canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/service/GrpcSequencerInitializationServiceTest.scala create mode 100644 canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/EnterpriseSequencerRateLimitManagerTest.scala create mode 100644 canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/EventCostCalculatorTest.scala create mode 100644 canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/SequencerMemberRateLimiterTest.scala create mode 100644 canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/store/DbTrafficLimitsStoreTest.scala create mode 100644 canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/store/TrafficLimitsStoreTest.scala create mode 100644 canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/store/TrafficLimitsStoreTestInMemory.scala create mode 100644 canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/store/TrafficLimitsStoreTestPostgres.scala create mode 100644 canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/store/TrafficLimitsStoreTesttH2.scala delete mode 100644 canton-3x/community/participant/src/main/resources/dar/AdminWorkflows.dar create mode 100644 canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/pruning/JournalGarbageCollector.scala delete mode 100644 canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/pruning/PruneObserver.scala create mode 100644 canton-3x/community/participant/src/test/scala/com/digitalasset/canton/participant/pruning/JournalGarbageCollectorTest.scala diff --git a/bazel_tools/proto.bzl b/bazel_tools/proto.bzl index 15f8a303a0..4e3742ec52 100644 --- a/bazel_tools/proto.bzl +++ b/bazel_tools/proto.bzl @@ -51,7 +51,8 @@ def _proto_gen_impl(ctx): descriptors = [depset for src in ctx.attr.srcs for depset in src[ProtoInfo].transitive_descriptor_sets.to_list()] args = [ "--descriptor_set_in=" + descriptor_set_delim.join([depset.path for depset in descriptors]), - "--{}_out={}:{}".format(ctx.attr.plugin_name, ",".join(ctx.attr.plugin_options), sources_out.path), + "--{}_out={}".format(ctx.attr.plugin_name, sources_out.path), + "--{}_opt={}".format(ctx.attr.plugin_name, ",".join(ctx.attr.plugin_options)), ] plugins = [] plugin_runfiles = [] @@ -179,7 +180,7 @@ def _proto_scala_srcs(name, grpc): "@com_github_grpc_grpc//src/proto/grpc/health/v1:health_proto_descriptor", ] if grpc else []) -def _proto_scala_deps(grpc, proto_deps): +def _proto_scala_deps(grpc, proto_deps, java_conversions): return [ "@maven//:com_google_api_grpc_proto_google_common_protos", "@maven//:com_google_protobuf_protobuf_java", @@ -194,7 +195,9 @@ def _proto_scala_deps(grpc, proto_deps): ] if grpc else []) + [ "%s_scala" % label for label in proto_deps - ] + ] + ([ + "@maven//:io_grpc_grpc_services", + ] if java_conversions else []) def proto_jars( name, @@ -289,10 +292,10 @@ def proto_jars( srcs = _proto_scala_srcs(name, grpc), plugin_exec = "//scala-protoc-plugins/scalapb:protoc-gen-scalapb", plugin_name = "scalapb", - plugin_options = ["grpc"] if grpc else [], + plugin_options = (["grpc"] if grpc else []) + (["java_conversions"] if java_conversions else []), ) - all_scala_deps = _proto_scala_deps(grpc, proto_deps) + all_scala_deps = _proto_scala_deps(grpc, proto_deps, java_conversions) scala_library( name = "%s_scala" % name, diff --git a/canton-3x/BUILD.bazel b/canton-3x/BUILD.bazel index 2c75038703..ac4f7eefcd 100644 --- a/canton-3x/BUILD.bazel +++ b/canton-3x/BUILD.bazel @@ -280,6 +280,42 @@ scala_library( ], ) +### community/admin-api + +proto_library( + name = "community_admin-api_proto", + srcs = glob(["community/admin-api/src/main/protobuf/**/*.proto"]), + strip_import_prefix = "community/admin-api/src/main/protobuf", + deps = [ + "@com_google_protobuf//:duration_proto", + "@com_google_protobuf//:empty_proto", + "@com_google_protobuf//:timestamp_proto", + "@com_google_protobuf//:wrappers_proto", + "@go_googleapis//google/rpc:status_proto", + "@scalapb//:scalapb_proto", + ], +) + +proto_gen( + name = "community_admin-api_proto_scala", + srcs = [":community_admin-api_proto"], + plugin_exec = "//scala-protoc-plugins/scalapb:protoc-gen-scalapb", + plugin_name = "scalapb", + plugin_options = [ + "flat_package", + "grpc", + ], +) + +scala_library( + name = "community_admin-api", + srcs = glob(["community/admin-api/src/main/protobuf/com/digitalasset/canton/version/ProtocolVersionAnnotation.scala"]), + scalacopts = [ + "-Xsource:3", + "-language:postfixOps", + ], +) + ### community/base ### proto_library( @@ -309,7 +345,7 @@ proto_gen( scala_library( name = "community_base", - srcs = glob(["community/base/src/main/scala/**/*.scala"]) + [":community_base_proto_scala"], + srcs = glob(["community/base/src/main/scala/**/*.scala"]) + [":community_base_proto_scala"] + [":community_admin-api_proto_scala"], plugins = [kind_projector_plugin], resource_strip_prefix = "canton-3x/community/base/src/main/resources", resources = glob(["community/base/src/main/resources/**"]), @@ -324,6 +360,7 @@ scala_library( ], deps = [ ":bindings-java", + ":community_admin-api", ":community_buildinfo", ":community_ledger_ledger-common", ":community_lib_slick_slick-fork", @@ -416,6 +453,7 @@ scala_library( ], deps = [ ":bindings-java", + ":community_admin-api", ":community_base", ":community_buildinfo", ":community_ledger_ledger-common", @@ -683,6 +721,7 @@ proto_library( srcs = glob(["community/domain/src/main/protobuf/**/*.proto"]), strip_import_prefix = "community/domain/src/main/protobuf", deps = [ + ":community_admin-api_proto", ":community_base_proto", "@com_google_protobuf//:duration_proto", "@com_google_protobuf//:empty_proto", @@ -715,6 +754,7 @@ scala_library( unused_dependency_checker_mode = "error", deps = [ ":bindings-java", + ":community_admin-api", ":community_base", ":community_common", ":community_ledger_ledger-common", @@ -770,33 +810,12 @@ scala_library( ### community/participant/ ### -# For now we include only the package service as the rest is not standalone -proto_library( - name = "community_participant_admin_proto", - srcs = glob(["community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/package_service.proto"]), - strip_import_prefix = "community/participant/src/main/protobuf", - deps = [ - "@com_google_protobuf//:empty_proto", - ], -) - -proto_gen( - name = "community_participant_admin_proto_scala", - srcs = [":community_participant_admin_proto"], - plugin_exec = "//scala-protoc-plugins/scalapb:protoc-gen-scalapb", - plugin_name = "scalapb", - plugin_options = [ - "grpc", - "flat_package", - ], - visibility = ["//daml-script:__subpackages__"], -) - proto_library( name = "community_participant_proto", srcs = glob(["community/participant/src/main/protobuf/**/*.proto"]), strip_import_prefix = "community/participant/src/main/protobuf", deps = [ + ":community_admin-api_proto", ":community_base_proto", "@com_google_protobuf//:duration_proto", "@com_google_protobuf//:empty_proto", @@ -818,10 +837,25 @@ proto_gen( ], ) -copy_file( +genrule( name = "community_participant_admin-workflows_dar", - src = "community/participant/src/main/resources/dar/AdminWorkflows.dar", - out = "AdminWorkflows.dar", + srcs = glob(["community/participant/src/main/daml/*"]) + [ + "//daml-script/daml3:daml3-script-2.1.dar", + ], + outs = ["AdminWorkflows.dar"], + cmd = """ + set -euo pipefail + project_dir=$$(dirname $(location community/participant/src/main/daml/daml.yaml)) + tmpdir=$$(mktemp -d) + trap "rm -rf $$tmpdir" EXIT + cp -r $$project_dir/* $$tmpdir + cp $(location //daml-script/daml3:daml3-script-2.1.dar) $$tmpdir + sed -i 's/sdk-version:.*/sdk-version: {sdk_version}/' $$tmpdir/daml.yaml + sed -i 's/daml3-script/daml3-script-2.1.dar/' $$tmpdir/daml.yaml + $(location //compiler/damlc) build --project-root=$$tmpdir --ghc-option=-Werror -o $$PWD/$@ + """.format(sdk_version = sdk_version), + tools = ["//compiler/damlc"], + visibility = ["//visibility:public"], ) genrule( @@ -882,6 +916,7 @@ scala_library( ], deps = [ ":bindings-java", + ":community_admin-api", ":community_base", ":community_common", ":community_ledger_ledger-api-core", @@ -966,6 +1001,7 @@ scala_library( unused_dependency_checker_mode = "error", deps = [ ":bindings-java", + ":community_admin-api", ":community_base", ":community_buildinfo", ":community_common", diff --git a/canton-3x/community/base/src/main/protobuf/com/digitalasset/canton/domain/api/v0/sequencer_connection.proto b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/domain/v0/sequencer_connection.proto similarity index 64% rename from canton-3x/community/base/src/main/protobuf/com/digitalasset/canton/domain/api/v0/sequencer_connection.proto rename to canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/domain/v0/sequencer_connection.proto index 75678f9cad..5396d8949e 100644 --- a/canton-3x/community/base/src/main/protobuf/com/digitalasset/canton/domain/api/v0/sequencer_connection.proto +++ b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/domain/v0/sequencer_connection.proto @@ -3,15 +3,11 @@ syntax = "proto3"; -package com.digitalasset.canton.domain.api.v0; +package com.digitalasset.canton.admin.domain.v0; import "google/protobuf/wrappers.proto"; -import "scalapb/scalapb.proto"; -// Client configuration for how members should connect to the sequencer of a domain. message SequencerConnection { - option (scalapb.message).companion_extends = "com.digitalasset.canton.version.StorageProtoVersion"; - oneof type { Grpc grpc = 2; } @@ -26,7 +22,3 @@ message SequencerConnection { google.protobuf.BytesValue customTrustCertificates = 3; } } - -enum SequencerApiType { - Grpc = 0; -} diff --git a/canton-3x/community/base/src/main/protobuf/com/digitalasset/canton/domain/api/v1/sequencer_connection.proto b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/domain/v1/sequencer_connection.proto similarity index 76% rename from canton-3x/community/base/src/main/protobuf/com/digitalasset/canton/domain/api/v1/sequencer_connection.proto rename to canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/domain/v1/sequencer_connection.proto index fb850f73ce..761bec83fb 100644 --- a/canton-3x/community/base/src/main/protobuf/com/digitalasset/canton/domain/api/v1/sequencer_connection.proto +++ b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/domain/v1/sequencer_connection.proto @@ -3,15 +3,15 @@ syntax = "proto3"; -package com.digitalasset.canton.domain.api.v1; +package com.digitalasset.canton.admin.domain.v1; -import "com/digitalasset/canton/domain/api/v0/sequencer_connection.proto"; +import "com/digitalasset/canton/admin/domain/v0/sequencer_connection.proto"; import "scalapb/scalapb.proto"; message SequencerConnections { option (scalapb.message).companion_extends = "com.digitalasset.canton.version.StorageProtoVersion"; - repeated com.digitalasset.canton.domain.api.v0.SequencerConnection sequencer_connections = 1; + repeated com.digitalasset.canton.admin.domain.v0.SequencerConnection sequencer_connections = 1; // This field determines the minimum level of agreement, or consensus, required among the sequencers before a message // is considered reliable and accepted by the system. diff --git a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/domain_connectivity.proto b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/domain_connectivity.proto similarity index 93% rename from canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/domain_connectivity.proto rename to canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/domain_connectivity.proto index ca4b772793..a09c5ebca1 100644 --- a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/domain_connectivity.proto +++ b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/domain_connectivity.proto @@ -3,10 +3,10 @@ syntax = "proto3"; -package com.digitalasset.canton.participant.admin.v0; +package com.digitalasset.canton.admin.participant.v0; -import "com/digitalasset/canton/domain/api/v0/sequencer_connection.proto"; -import "com/digitalasset/canton/time/admin/v0/time_tracker_config.proto"; +import "com/digitalasset/canton/admin/time/v0/time_tracker_config.proto"; +import "com/digitalasset/canton/admin/domain/v0/sequencer_connection.proto"; import "google/protobuf/duration.proto"; /** @@ -42,7 +42,7 @@ message DomainConnectionConfig { // participant local identifier of the target domain string domain_alias = 1; // connection information to sequencer - repeated com.digitalasset.canton.domain.api.v0.SequencerConnection sequencerConnections = 2; + repeated com.digitalasset.canton.admin.domain.v0.SequencerConnection sequencerConnections = 2; // if false, then domain needs to be manually connected to (default false) bool manual_connect = 3; // optional domainId (if TLS isn't to be trusted) @@ -54,7 +54,7 @@ message DomainConnectionConfig { // maximum delay before an attempt to reconnect to the sequencer google.protobuf.Duration maxRetryDelay = 7; // configuration for how time is tracked and requested on this domain - com.digitalasset.canton.time.admin.v0.DomainTimeTrackerConfig timeTracker = 8; + com.digitalasset.canton.admin.time.v0.DomainTimeTrackerConfig timeTracker = 8; // This field determines the minimum level of agreement, or consensus, required among the sequencers before a message // is considered reliable and accepted by the system. // The value set here should not be zero. However, to maintain backward compatibility with older clients, a zero value diff --git a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/enterprise_participant_replication_service.proto b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/enterprise_participant_replication_service.proto similarity index 86% rename from canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/enterprise_participant_replication_service.proto rename to canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/enterprise_participant_replication_service.proto index b05151eb97..35dc566187 100644 --- a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/enterprise_participant_replication_service.proto +++ b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/enterprise_participant_replication_service.proto @@ -3,7 +3,7 @@ syntax = "proto3"; -package com.digitalasset.canton.participant.admin.v0; +package com.digitalasset.canton.admin.participant.v0; service EnterpriseParticipantReplicationService { rpc SetPassive(SetPassive.Request) returns (SetPassive.Response); diff --git a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/inspection_service.proto b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/inspection_service.proto similarity index 98% rename from canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/inspection_service.proto rename to canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/inspection_service.proto index 0997458b10..929af128b9 100644 --- a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/inspection_service.proto +++ b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/inspection_service.proto @@ -3,7 +3,7 @@ syntax = "proto3"; -package com.digitalasset.canton.participant.admin.v0; +package com.digitalasset.canton.admin.participant.v0; import "google/protobuf/timestamp.proto"; diff --git a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/package_service.proto b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/package_service.proto similarity index 98% rename from canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/package_service.proto rename to canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/package_service.proto index d0ad68678e..f7c4b71163 100644 --- a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/package_service.proto +++ b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/package_service.proto @@ -3,7 +3,7 @@ syntax = "proto3"; -package com.digitalasset.canton.participant.admin.v0; +package com.digitalasset.canton.admin.participant.v0; import "google/protobuf/empty.proto"; diff --git a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/participant_repair_service.proto b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/participant_repair_service.proto similarity index 96% rename from canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/participant_repair_service.proto rename to canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/participant_repair_service.proto index 57ff3178d0..a1b9ee8ea6 100644 --- a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/participant_repair_service.proto +++ b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/participant_repair_service.proto @@ -3,9 +3,9 @@ syntax = "proto3"; -package com.digitalasset.canton.participant.admin.v0; +package com.digitalasset.canton.admin.participant.v0; -import "com/digitalasset/canton/participant/admin/v0/domain_connectivity.proto"; +import "com/digitalasset/canton/admin/participant/v0/domain_connectivity.proto"; import "google/protobuf/timestamp.proto"; import "google/protobuf/wrappers.proto"; diff --git a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/party_name_management.proto b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/party_name_management.proto similarity index 93% rename from canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/party_name_management.proto rename to canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/party_name_management.proto index c909b719b5..6ddd7bfac9 100644 --- a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/party_name_management.proto +++ b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/party_name_management.proto @@ -3,7 +3,7 @@ syntax = "proto3"; -package com.digitalasset.canton.participant.admin.v0; +package com.digitalasset.canton.admin.participant.v0; /** * Local participant service allowing to set the display name for a party diff --git a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/ping_pong_service.proto b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/ping_pong_service.proto similarity index 93% rename from canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/ping_pong_service.proto rename to canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/ping_pong_service.proto index e9d0fd5d09..c76b4f1ef9 100644 --- a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/ping_pong_service.proto +++ b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/ping_pong_service.proto @@ -3,7 +3,7 @@ syntax = "proto3"; -package com.digitalasset.canton.participant.admin.v0; +package com.digitalasset.canton.admin.participant.v0; service PingService { rpc ping(PingRequest) returns (PingResponse); diff --git a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/pruning_service.proto b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/pruning_service.proto similarity index 67% rename from canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/pruning_service.proto rename to canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/pruning_service.proto index 6a00d3ed2e..3cf4996a99 100644 --- a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/pruning_service.proto +++ b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/pruning_service.proto @@ -3,9 +3,9 @@ syntax = "proto3"; -package com.digitalasset.canton.participant.admin.v0; +package com.digitalasset.canton.admin.participant.v0; -import "com/digitalasset/canton/pruning/admin/v0/pruning.proto"; +import "com/digitalasset/canton/admin/pruning/v0/pruning.proto"; import "google/protobuf/timestamp.proto"; // Canton-internal pruning service that prunes only canton state, but leaves the ledger-api @@ -32,25 +32,25 @@ service PruningService { // or duration. // - ``FAILED_PRECONDITION``: if automatic background pruning has not been enabled // or if invoked on a participant running the Community Edition. - rpc SetSchedule(com.digitalasset.canton.pruning.admin.v0.SetSchedule.Request) returns (com.digitalasset.canton.pruning.admin.v0.SetSchedule.Response); + rpc SetSchedule(com.digitalasset.canton.admin.pruning.v0.SetSchedule.Request) returns (com.digitalasset.canton.admin.pruning.v0.SetSchedule.Response); // Enable automatic pruning with participant-specific schedule parameters. - rpc SetParticipantSchedule(com.digitalasset.canton.pruning.admin.v0.SetParticipantSchedule.Request) returns (com.digitalasset.canton.pruning.admin.v0.SetParticipantSchedule.Response); + rpc SetParticipantSchedule(com.digitalasset.canton.admin.pruning.v0.SetParticipantSchedule.Request) returns (com.digitalasset.canton.admin.pruning.v0.SetParticipantSchedule.Response); // Modify individual pruning schedule parameters. // - ``INVALID_ARGUMENT``: if the payload is malformed or no schedule is configured - rpc SetCron(com.digitalasset.canton.pruning.admin.v0.SetCron.Request) returns (com.digitalasset.canton.pruning.admin.v0.SetCron.Response); - rpc SetMaxDuration(com.digitalasset.canton.pruning.admin.v0.SetMaxDuration.Request) returns (com.digitalasset.canton.pruning.admin.v0.SetMaxDuration.Response); - rpc SetRetention(com.digitalasset.canton.pruning.admin.v0.SetRetention.Request) returns (com.digitalasset.canton.pruning.admin.v0.SetRetention.Response); + rpc SetCron(com.digitalasset.canton.admin.pruning.v0.SetCron.Request) returns (com.digitalasset.canton.admin.pruning.v0.SetCron.Response); + rpc SetMaxDuration(com.digitalasset.canton.admin.pruning.v0.SetMaxDuration.Request) returns (com.digitalasset.canton.admin.pruning.v0.SetMaxDuration.Response); + rpc SetRetention(com.digitalasset.canton.admin.pruning.v0.SetRetention.Request) returns (com.digitalasset.canton.admin.pruning.v0.SetRetention.Response); // Disable automatic pruning and remove the persisted schedule configuration. - rpc ClearSchedule(com.digitalasset.canton.pruning.admin.v0.ClearSchedule.Request) returns (com.digitalasset.canton.pruning.admin.v0.ClearSchedule.Response); + rpc ClearSchedule(com.digitalasset.canton.admin.pruning.v0.ClearSchedule.Request) returns (com.digitalasset.canton.admin.pruning.v0.ClearSchedule.Response); // Retrieve the automatic pruning configuration. - rpc GetSchedule(com.digitalasset.canton.pruning.admin.v0.GetSchedule.Request) returns (com.digitalasset.canton.pruning.admin.v0.GetSchedule.Response); + rpc GetSchedule(com.digitalasset.canton.admin.pruning.v0.GetSchedule.Request) returns (com.digitalasset.canton.admin.pruning.v0.GetSchedule.Response); // Retrieve the automatic, participant-specific pruning configuration. - rpc GetParticipantSchedule(com.digitalasset.canton.pruning.admin.v0.GetParticipantSchedule.Request) returns (com.digitalasset.canton.pruning.admin.v0.GetParticipantSchedule.Response); + rpc GetParticipantSchedule(com.digitalasset.canton.admin.pruning.v0.GetParticipantSchedule.Request) returns (com.digitalasset.canton.admin.pruning.v0.GetParticipantSchedule.Response); } message PruneRequest { diff --git a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/resource_management_service.proto b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/resource_management_service.proto similarity index 94% rename from canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/resource_management_service.proto rename to canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/resource_management_service.proto index 14ee27b2f1..388a29759a 100644 --- a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/resource_management_service.proto +++ b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/resource_management_service.proto @@ -3,7 +3,7 @@ syntax = "proto3"; -package com.digitalasset.canton.participant.admin.v0; +package com.digitalasset.canton.admin.participant.v0; import "google/protobuf/empty.proto"; diff --git a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/traffic_control_service.proto b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/traffic_control_service.proto similarity index 71% rename from canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/traffic_control_service.proto rename to canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/traffic_control_service.proto index b51dc0516b..4bf46fc39b 100644 --- a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/traffic_control_service.proto +++ b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/traffic_control_service.proto @@ -3,9 +3,9 @@ syntax = "proto3"; -package com.digitalasset.canton.participant.admin.v0; +package com.digitalasset.canton.admin.participant.v0; -import "com/digitalasset/canton/traffic/v0/member_traffic_status.proto"; +import "com/digitalasset/canton/admin/traffic/v0/member_traffic_status.proto"; /* * Service to retrieve information about the traffic state of the participant. @@ -19,5 +19,5 @@ message TrafficControlStateRequest { } message TrafficControlStateResponse { - com.digitalasset.canton.traffic.v0.MemberTrafficStatus traffic_state = 1; + com.digitalasset.canton.admin.traffic.v0.MemberTrafficStatus traffic_state = 1; } diff --git a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/transfer_service.proto b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/transfer_service.proto similarity index 85% rename from canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/transfer_service.proto rename to canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/transfer_service.proto index eb7805f770..998aadfbd2 100644 --- a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v0/transfer_service.proto +++ b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v0/transfer_service.proto @@ -3,9 +3,8 @@ syntax = "proto3"; -package com.digitalasset.canton.participant.admin.v0; +package com.digitalasset.canton.admin.participant.v0; -import "com/digitalasset/canton/protocol/v0/participant_transfer.proto"; import "google/protobuf/timestamp.proto"; // Supports transferring contracts from one domain to another @@ -20,6 +19,11 @@ service TransferService { rpc TransferSearch(AdminTransferSearchQuery) returns (AdminTransferSearchResponse); } +message TransferId { + string source_domain = 1; + google.protobuf.Timestamp timestamp = 2; +} + message AdminTransferOutRequest { string submitting_party = 1; string contract_id = 2; @@ -32,13 +36,13 @@ message AdminTransferOutRequest { } message AdminTransferOutResponse { - com.digitalasset.canton.protocol.v0.TransferId transfer_id = 1; + TransferId transfer_id = 1; } message AdminTransferInRequest { string submitting_party_id = 1; string target_domain = 2; - com.digitalasset.canton.protocol.v0.TransferId transfer_id = 3; + TransferId transfer_id = 3; string application_id = 4; string submission_id = 5; // optional string workflow_id = 6; // optional @@ -60,7 +64,7 @@ message AdminTransferSearchResponse { message TransferSearchResult { string contract_id = 1; - com.digitalasset.canton.protocol.v0.TransferId transfer_id = 2; + TransferId transfer_id = 2; string origin_domain = 3; string target_domain = 4; string submitting_party = 5; diff --git a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v1/participant_repair_service.proto b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v1/participant_repair_service.proto similarity index 88% rename from canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v1/participant_repair_service.proto rename to canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v1/participant_repair_service.proto index 9260fe9067..9c84337e55 100644 --- a/canton-3x/community/participant/src/main/protobuf/com/digitalasset/canton/participant/admin/v1/participant_repair_service.proto +++ b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/participant/v1/participant_repair_service.proto @@ -3,7 +3,7 @@ syntax = "proto3"; -package com.digitalasset.canton.participant.admin.v1; +package com.digitalasset.canton.admin.participant.v1; import "scalapb/scalapb.proto"; diff --git a/canton-3x/community/base/src/main/protobuf/com/digitalasset/canton/pruning/admin/v0/pruning.proto b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/pruning/v0/pruning.proto similarity index 97% rename from canton-3x/community/base/src/main/protobuf/com/digitalasset/canton/pruning/admin/v0/pruning.proto rename to canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/pruning/v0/pruning.proto index 0c452fd5c3..46eb1d2049 100644 --- a/canton-3x/community/base/src/main/protobuf/com/digitalasset/canton/pruning/admin/v0/pruning.proto +++ b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/pruning/v0/pruning.proto @@ -3,7 +3,7 @@ syntax = "proto3"; -package com.digitalasset.canton.pruning.admin.v0; +package com.digitalasset.canton.admin.pruning.v0; import "google/protobuf/duration.proto"; import "google/protobuf/timestamp.proto"; diff --git a/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/scalapb/package.proto b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/scalapb/package.proto new file mode 100644 index 0000000000..5f6ea0c34d --- /dev/null +++ b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/scalapb/package.proto @@ -0,0 +1,15 @@ +// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +import "scalapb/scalapb.proto"; + +package com.digitalasset.canton.admin; + +option (scalapb.options) = { + scope: PACKAGE + preserve_unknown_fields: false + no_default_values_in_constructor: true +}; + diff --git a/canton-3x/community/base/src/main/protobuf/com/digitalasset/canton/time/admin/v0/time_tracker_config.proto b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/time/v0/time_tracker_config.proto similarity index 94% rename from canton-3x/community/base/src/main/protobuf/com/digitalasset/canton/time/admin/v0/time_tracker_config.proto rename to canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/time/v0/time_tracker_config.proto index 5737ccd65a..fdbac9a21a 100644 --- a/canton-3x/community/base/src/main/protobuf/com/digitalasset/canton/time/admin/v0/time_tracker_config.proto +++ b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/time/v0/time_tracker_config.proto @@ -3,7 +3,7 @@ syntax = "proto3"; -package com.digitalasset.canton.time.admin.v0; +package com.digitalasset.canton.admin.time.v0; import "google/protobuf/duration.proto"; diff --git a/canton-3x/community/base/src/main/protobuf/com/digitalasset/canton/traffic/v0/member_traffic_status.proto b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/traffic/v0/member_traffic_status.proto similarity index 96% rename from canton-3x/community/base/src/main/protobuf/com/digitalasset/canton/traffic/v0/member_traffic_status.proto rename to canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/traffic/v0/member_traffic_status.proto index 4becc720f4..84c70a9d8c 100644 --- a/canton-3x/community/base/src/main/protobuf/com/digitalasset/canton/traffic/v0/member_traffic_status.proto +++ b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/admin/traffic/v0/member_traffic_status.proto @@ -3,7 +3,7 @@ syntax = "proto3"; -package com.digitalasset.canton.traffic.v0; +package com.digitalasset.canton.admin.traffic.v0; import "google/protobuf/timestamp.proto"; import "google/protobuf/wrappers.proto"; diff --git a/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/version/ProtocolVersionAnnotation.scala b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/version/ProtocolVersionAnnotation.scala new file mode 100644 index 0000000000..d875935840 --- /dev/null +++ b/canton-3x/community/admin-api/src/main/protobuf/com/digitalasset/canton/version/ProtocolVersionAnnotation.scala @@ -0,0 +1,35 @@ +package com.digitalasset.canton.version + +object ProtocolVersionAnnotation { + + /** Type-level marker for whether a protocol version is stable */ + sealed trait Status + + /** Marker for unstable protocol versions */ + sealed trait Unstable extends Status + + /** Marker for stable protocol versions */ + sealed trait Stable extends Status +} + +/** Marker trait for Protobuf messages generated by scalapb + * that are used in some stable protocol versions + * + * Implements both [[com.digitalasset.canton.version.ProtocolVersionAnnotation.Stable]] and + * [[com.digitalasset.canton.version.ProtocolVersionAnnotation.Unstable]] means that [[StableProtoVersion]] + * messages can be used in stable and unstable protocol versions. + */ +trait StableProtoVersion + extends ProtocolVersionAnnotation.Stable + with ProtocolVersionAnnotation.Unstable + +/** Marker trait for Protobuf messages generated by scalapb + * that are used only unstable protocol versions + */ +trait UnstableProtoVersion extends ProtocolVersionAnnotation.Unstable + +/** Marker trait for Protobuf messages generated by scalapb + * that are used only to persist data in node storage. + * These messages are never exchanged as part of a protocol. + */ +trait StorageProtoVersion diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/EnterpriseMediatorAdministrationCommands.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/EnterpriseMediatorAdministrationCommands.scala index 54075c59c2..3ebd47adcc 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/EnterpriseMediatorAdministrationCommands.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/EnterpriseMediatorAdministrationCommands.scala @@ -9,6 +9,7 @@ import com.digitalasset.canton.admin.api.client.commands.GrpcAdminCommand.{ DefaultUnboundedTimeout, TimeoutType, } +import com.digitalasset.canton.admin.pruning.v0.LocatePruningTimestamp import com.digitalasset.canton.config.RequireTypes.PositiveInt import com.digitalasset.canton.crypto.{Fingerprint, PublicKey} import com.digitalasset.canton.data.CantonTimestamp @@ -21,7 +22,6 @@ import com.digitalasset.canton.domain.mediator.admin.gprc.{ InitializeMediatorResponseX, } import com.digitalasset.canton.protocol.StaticDomainParameters -import com.digitalasset.canton.pruning.admin.v0.LocatePruningTimestamp import com.digitalasset.canton.sequencing.SequencerConnections import com.digitalasset.canton.topology.store.StoredTopologyTransactions import com.digitalasset.canton.topology.transaction.TopologyChangeOp diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/EnterpriseSequencerAdminCommands.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/EnterpriseSequencerAdminCommands.scala index e9d73ec595..255894aea7 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/EnterpriseSequencerAdminCommands.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/EnterpriseSequencerAdminCommands.scala @@ -10,6 +10,7 @@ import com.digitalasset.canton.admin.api.client.commands.GrpcAdminCommand.{ TimeoutType, } import com.digitalasset.canton.admin.api.client.data.StaticDomainParameters +import com.digitalasset.canton.admin.pruning.v0.LocatePruningTimestamp import com.digitalasset.canton.config.RequireTypes.PositiveInt import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.domain.admin.v2.SequencerInitializationServiceGrpc @@ -21,7 +22,6 @@ import com.digitalasset.canton.domain.sequencing.admin.grpc.{ InitializeSequencerResponseX, } import com.digitalasset.canton.domain.sequencing.sequencer.{LedgerIdentity, SequencerSnapshot} -import com.digitalasset.canton.pruning.admin.v0.LocatePruningTimestamp import com.digitalasset.canton.topology.store.StoredTopologyTransactions import com.digitalasset.canton.topology.store.StoredTopologyTransactionsX.GenericStoredTopologyTransactionsX import com.digitalasset.canton.topology.transaction.TopologyChangeOp diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/LedgerApiV2Commands.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/LedgerApiV2Commands.scala index 8f4f5d7d81..42a329007b 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/LedgerApiV2Commands.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/LedgerApiV2Commands.scala @@ -101,11 +101,19 @@ object LedgerApiV2Commands { object UpdateService { - sealed trait UpdateTreeWrapper - sealed trait UpdateWrapper + sealed trait UpdateTreeWrapper { + def updateId: String + } + sealed trait UpdateWrapper { + def updateId: String + } final case class TransactionTreeWrapper(transactionTree: TransactionTree) - extends UpdateTreeWrapper - final case class TransactionWrapper(transaction: Transaction) extends UpdateWrapper + extends UpdateTreeWrapper { + override def updateId: String = transactionTree.updateId + } + final case class TransactionWrapper(transaction: Transaction) extends UpdateWrapper { + override def updateId: String = transaction.updateId + } sealed trait ReassignmentWrapper extends UpdateTreeWrapper with UpdateWrapper { def reassignment: Reassignment } @@ -125,9 +133,13 @@ object LedgerApiV2Commands { } } final case class AssignedWrapper(reassignment: Reassignment, assignedEvent: AssignedEvent) - extends ReassignmentWrapper + extends ReassignmentWrapper { + override def updateId: String = reassignment.updateId + } final case class UnassignedWrapper(reassignment: Reassignment, unassignedEvent: UnassignedEvent) - extends ReassignmentWrapper + extends ReassignmentWrapper { + override def updateId: String = reassignment.updateId + } trait BaseCommand[Req, Resp, Res] extends GrpcAdminCommand[Req, Resp, Res] { override type Svc = UpdateServiceStub @@ -279,7 +291,7 @@ object LedgerApiV2Commands { def submissionId: String def minLedgerTimeAbs: Option[Instant] def disclosedContracts: Seq[DisclosedContract] - def domainId: DomainId + def domainId: Option[DomainId] def applicationId: String protected def mkCommand: Commands = Commands( @@ -304,7 +316,7 @@ object LedgerApiV2Commands { minLedgerTimeAbs = minLedgerTimeAbs.map(ProtoConverter.InstantConverter.toProtoPrimitive), submissionId = submissionId, disclosedContracts = disclosedContracts, - domainId = domainId.toProtoPrimitive, + domainId = domainId.map(_.toProtoPrimitive).getOrElse(""), ) override def pretty: Pretty[this.type] = @@ -337,7 +349,7 @@ object LedgerApiV2Commands { override val submissionId: String, override val minLedgerTimeAbs: Option[Instant], override val disclosedContracts: Seq[DisclosedContract], - override val domainId: DomainId, + override val domainId: Option[DomainId], override val applicationId: String, ) extends SubmitCommand with BaseCommand[SubmitRequest, SubmitResponse, Unit] { @@ -457,7 +469,7 @@ object LedgerApiV2Commands { override val submissionId: String, override val minLedgerTimeAbs: Option[Instant], override val disclosedContracts: Seq[DisclosedContract], - override val domainId: DomainId, + override val domainId: Option[DomainId], override val applicationId: String, ) extends SubmitCommand with BaseCommand[ @@ -494,7 +506,7 @@ object LedgerApiV2Commands { override val submissionId: String, override val minLedgerTimeAbs: Option[Instant], override val disclosedContracts: Seq[DisclosedContract], - override val domainId: DomainId, + override val domainId: Option[DomainId], override val applicationId: String, ) extends SubmitCommand with BaseCommand[SubmitAndWaitRequest, SubmitAndWaitForTransactionResponse, Transaction] { diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/ParticipantAdminCommands.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/ParticipantAdminCommands.scala index 69d84a25fc..24983ce975 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/ParticipantAdminCommands.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/ParticipantAdminCommands.scala @@ -16,29 +16,30 @@ import com.digitalasset.canton.admin.api.client.data.{ ListConnectedDomainsResult, ParticipantPruningSchedule, } +import com.digitalasset.canton.admin.participant.v0 +import com.digitalasset.canton.admin.participant.v0.DomainConnectivityServiceGrpc.DomainConnectivityServiceStub +import com.digitalasset.canton.admin.participant.v0.EnterpriseParticipantReplicationServiceGrpc.EnterpriseParticipantReplicationServiceStub +import com.digitalasset.canton.admin.participant.v0.InspectionServiceGrpc.InspectionServiceStub +import com.digitalasset.canton.admin.participant.v0.PackageServiceGrpc.PackageServiceStub +import com.digitalasset.canton.admin.participant.v0.ParticipantRepairServiceGrpc.ParticipantRepairServiceStub +import com.digitalasset.canton.admin.participant.v0.PartyNameManagementServiceGrpc.PartyNameManagementServiceStub +import com.digitalasset.canton.admin.participant.v0.PingServiceGrpc.PingServiceStub +import com.digitalasset.canton.admin.participant.v0.PruningServiceGrpc.PruningServiceStub +import com.digitalasset.canton.admin.participant.v0.ResourceManagementServiceGrpc.ResourceManagementServiceStub +import com.digitalasset.canton.admin.participant.v0.TransferServiceGrpc.TransferServiceStub +import com.digitalasset.canton.admin.participant.v0.{ResourceLimits as _, *} +import com.digitalasset.canton.admin.pruning import com.digitalasset.canton.config.RequireTypes.PositiveInt import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.logging.TracedLogger +import com.digitalasset.canton.participant.admin.ResourceLimits import com.digitalasset.canton.participant.admin.grpc.{ GrpcParticipantRepairService, TransferSearchResult, } -import com.digitalasset.canton.participant.admin.v0.DomainConnectivityServiceGrpc.DomainConnectivityServiceStub -import com.digitalasset.canton.participant.admin.v0.EnterpriseParticipantReplicationServiceGrpc.EnterpriseParticipantReplicationServiceStub -import com.digitalasset.canton.participant.admin.v0.InspectionServiceGrpc.InspectionServiceStub -import com.digitalasset.canton.participant.admin.v0.PackageServiceGrpc.PackageServiceStub -import com.digitalasset.canton.participant.admin.v0.ParticipantRepairServiceGrpc.ParticipantRepairServiceStub -import com.digitalasset.canton.participant.admin.v0.PartyNameManagementServiceGrpc.PartyNameManagementServiceStub -import com.digitalasset.canton.participant.admin.v0.PingServiceGrpc.PingServiceStub -import com.digitalasset.canton.participant.admin.v0.PruningServiceGrpc.PruningServiceStub -import com.digitalasset.canton.participant.admin.v0.ResourceManagementServiceGrpc.ResourceManagementServiceStub -import com.digitalasset.canton.participant.admin.v0.TransferServiceGrpc.TransferServiceStub -import com.digitalasset.canton.participant.admin.v0.{ResourceLimits as _, *} -import com.digitalasset.canton.participant.admin.{ResourceLimits, v0} import com.digitalasset.canton.participant.domain.DomainConnectionConfig as CDomainConnectionConfig import com.digitalasset.canton.participant.sync.UpstreamOffsetConvert -import com.digitalasset.canton.protocol.{LfContractId, TransferId, v0 as v0proto} -import com.digitalasset.canton.pruning.admin +import com.digitalasset.canton.protocol.{LfContractId, TransferId} import com.digitalasset.canton.serialization.ProtoConverter.InstantConverter import com.digitalasset.canton.topology.{DomainId, PartyId} import com.digitalasset.canton.tracing.TraceContext @@ -820,14 +821,14 @@ object ParticipantAdminCommands { override def handleResponse(response: AdminTransferOutResponse): Either[String, TransferId] = response match { case AdminTransferOutResponse(Some(transferIdP)) => - TransferId.fromProtoV0(transferIdP).leftMap(_.toString) + TransferId.fromAdminProtoV0(transferIdP).leftMap(_.toString) case AdminTransferOutResponse(None) => Left("Empty TransferOutResponse") } } final case class TransferIn( submittingParty: PartyId, - transferId: v0proto.TransferId, + transferId: v0.TransferId, targetDomain: DomainAlias, applicationId: LedgerApplicationId, submissionId: String, @@ -1094,17 +1095,17 @@ object ParticipantAdminCommands { retention: config.PositiveDurationSeconds, pruneInternallyOnly: Boolean, ) extends Base[ - admin.v0.SetParticipantSchedule.Request, - admin.v0.SetParticipantSchedule.Response, + pruning.v0.SetParticipantSchedule.Request, + pruning.v0.SetParticipantSchedule.Response, Unit, ] { - override def createRequest(): Right[String, admin.v0.SetParticipantSchedule.Request] = + override def createRequest(): Right[String, pruning.v0.SetParticipantSchedule.Request] = Right( - admin.v0.SetParticipantSchedule.Request( + pruning.v0.SetParticipantSchedule.Request( Some( - admin.v0.ParticipantPruningSchedule( + pruning.v0.ParticipantPruningSchedule( Some( - admin.v0.PruningSchedule( + pruning.v0.PruningSchedule( cron, Some(maxDuration.toProtoPrimitive), Some(retention.toProtoPrimitive), @@ -1118,35 +1119,37 @@ object ParticipantAdminCommands { override def submitRequest( service: Svc, - request: admin.v0.SetParticipantSchedule.Request, - ): Future[admin.v0.SetParticipantSchedule.Response] = service.setParticipantSchedule(request) + request: pruning.v0.SetParticipantSchedule.Request, + ): Future[pruning.v0.SetParticipantSchedule.Response] = + service.setParticipantSchedule(request) override def handleResponse( - response: admin.v0.SetParticipantSchedule.Response + response: pruning.v0.SetParticipantSchedule.Response ): Either[String, Unit] = response match { - case admin.v0.SetParticipantSchedule.Response() => Right(()) + case pruning.v0.SetParticipantSchedule.Response() => Right(()) } } final case class GetParticipantScheduleCommand() extends Base[ - admin.v0.GetParticipantSchedule.Request, - admin.v0.GetParticipantSchedule.Response, + pruning.v0.GetParticipantSchedule.Request, + pruning.v0.GetParticipantSchedule.Response, Option[ParticipantPruningSchedule], ] { - override def createRequest(): Right[String, admin.v0.GetParticipantSchedule.Request] = + override def createRequest(): Right[String, pruning.v0.GetParticipantSchedule.Request] = Right( - admin.v0.GetParticipantSchedule.Request() + pruning.v0.GetParticipantSchedule.Request() ) override def submitRequest( service: Svc, - request: admin.v0.GetParticipantSchedule.Request, - ): Future[admin.v0.GetParticipantSchedule.Response] = service.getParticipantSchedule(request) + request: pruning.v0.GetParticipantSchedule.Request, + ): Future[pruning.v0.GetParticipantSchedule.Response] = + service.getParticipantSchedule(request) override def handleResponse( - response: admin.v0.GetParticipantSchedule.Response + response: pruning.v0.GetParticipantSchedule.Response ): Either[String, Option[ParticipantPruningSchedule]] = response.schedule.fold( Right(None): Either[String, Option[ParticipantPruningSchedule]] diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/PruningSchedulerCommands.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/PruningSchedulerCommands.scala index 3fd8cec61d..5e0208a2c1 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/PruningSchedulerCommands.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/commands/PruningSchedulerCommands.scala @@ -5,9 +5,9 @@ package com.digitalasset.canton.admin.api.client.commands import cats.syntax.either.* import com.digitalasset.canton.admin.api.client.data.PruningSchedule +import com.digitalasset.canton.admin.pruning.v0 +import com.digitalasset.canton.admin.pruning.v0.{PruningSchedule as PruningScheduleP, *} import com.digitalasset.canton.config.PositiveDurationSeconds -import com.digitalasset.canton.pruning.admin.v0 -import com.digitalasset.canton.pruning.admin.v0.{PruningSchedule as PruningScheduleP, *} import io.grpc.ManagedChannel import io.grpc.stub.AbstractStub diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/data/ConsoleApiDataObjects.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/data/ConsoleApiDataObjects.scala index c086344213..0dbc0b98f3 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/data/ConsoleApiDataObjects.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/data/ConsoleApiDataObjects.scala @@ -4,7 +4,7 @@ package com.digitalasset.canton.admin.api.client.data import com.digitalasset.canton.DomainAlias -import com.digitalasset.canton.participant.admin.{v0 as participantAdminV0} +import com.digitalasset.canton.admin.participant.{v0 as participantAdminV0} import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult import com.digitalasset.canton.topology.* diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/data/DomainParameters.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/data/DomainParameters.scala index 8ad8fcf37a..62e22b7fc3 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/data/DomainParameters.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/data/DomainParameters.scala @@ -8,6 +8,7 @@ import com.daml.nonempty.NonEmptyUtil import com.digitalasset.canton.admin.api.client.data.crypto.* import com.digitalasset.canton.config.RequireTypes.NonNegativeInt import com.digitalasset.canton.config.{ + CommunityCryptoConfig, CryptoConfig, NonNegativeFiniteDuration, PositiveDurationSeconds, @@ -87,6 +88,9 @@ object StaticDomainParameters { StaticDomainParameters(internal) } + lazy val defaultsWithoutKMS: StaticDomainParameters = + defaults(CommunityCryptoConfig()) + // This method is unsafe. Not prefixing by `try` to have nicer docs snippets. def defaults( cryptoConfig: CryptoConfig diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/data/PruningSchedule.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/data/PruningSchedule.scala index ea390346fd..4c6e6a5c5b 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/data/PruningSchedule.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/admin/api/client/data/PruningSchedule.scala @@ -3,7 +3,7 @@ package com.digitalasset.canton.admin.api.client.data -import com.digitalasset.canton.pruning.admin.v0 +import com.digitalasset.canton.admin.pruning.v0 import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult import com.digitalasset.canton.{config, participant, scheduler} diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/config/CantonCommunityConfig.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/config/CantonCommunityConfig.scala index 07a583c87b..9d4d3e4625 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/config/CantonCommunityConfig.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/config/CantonCommunityConfig.scala @@ -14,6 +14,10 @@ import com.digitalasset.canton.domain.config.{ RemoteDomainConfig, } import com.digitalasset.canton.domain.mediator.{CommunityMediatorNodeXConfig, RemoteMediatorConfig} +import com.digitalasset.canton.domain.sequencing.config.{ + CommunitySequencerNodeXConfig, + RemoteSequencerConfig, +} import com.digitalasset.canton.logging.{ErrorLoggingContext, NamedLoggerFactory, TracedLogger} import com.digitalasset.canton.participant.config.{ CommunityParticipantConfig, @@ -33,10 +37,12 @@ final case class CantonCommunityConfig( domains: Map[InstanceName, CommunityDomainConfig] = Map.empty, participants: Map[InstanceName, CommunityParticipantConfig] = Map.empty, participantsX: Map[InstanceName, CommunityParticipantConfig] = Map.empty, + sequencersX: Map[InstanceName, CommunitySequencerNodeXConfig] = Map.empty, mediatorsX: Map[InstanceName, CommunityMediatorNodeXConfig] = Map.empty, remoteDomains: Map[InstanceName, RemoteDomainConfig] = Map.empty, remoteParticipants: Map[InstanceName, RemoteParticipantConfig] = Map.empty, remoteParticipantsX: Map[InstanceName, RemoteParticipantConfig] = Map.empty, + remoteSequencersX: Map[InstanceName, RemoteSequencerConfig] = Map.empty, remoteMediatorsX: Map[InstanceName, RemoteMediatorConfig] = Map.empty, monitoring: MonitoringConfig = MonitoringConfig(), parameters: CantonParameters = CantonParameters(), @@ -47,6 +53,7 @@ final case class CantonCommunityConfig( override type DomainConfigType = CommunityDomainConfig override type ParticipantConfigType = CommunityParticipantConfig override type MediatorNodeXConfigType = CommunityMediatorNodeXConfig + override type SequencerNodeXConfigType = CommunitySequencerNodeXConfig /** renders the config as json (used for dumping config for diagnostic purposes) */ override def dumpString: String = CantonCommunityConfig.makeConfidentialString(this) @@ -102,6 +109,8 @@ object CantonCommunityConfig { deriveReader[CommunityDomainConfig].applyDeprecations implicit val communityParticipantConfigReader: ConfigReader[CommunityParticipantConfig] = deriveReader[CommunityParticipantConfig].applyDeprecations + implicit val communitySequencerNodeXConfigReader: ConfigReader[CommunitySequencerNodeXConfig] = + deriveReader[CommunitySequencerNodeXConfig] implicit val communityMediatorNodeXConfigReader: ConfigReader[CommunityMediatorNodeXConfig] = deriveReader[CommunityMediatorNodeXConfig] @@ -116,6 +125,8 @@ object CantonCommunityConfig { deriveWriter[CommunityDomainConfig] implicit val communityParticipantConfigWriter: ConfigWriter[CommunityParticipantConfig] = deriveWriter[CommunityParticipantConfig] + implicit val communitySequencerNodeXConfigWriter: ConfigWriter[CommunitySequencerNodeXConfig] = + deriveWriter[CommunitySequencerNodeXConfig] implicit val communityMediatorNodeXConfigWriter: ConfigWriter[CommunityMediatorNodeXConfig] = deriveWriter[CommunityMediatorNodeXConfig] diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/config/CantonConfig.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/config/CantonConfig.scala index fc864dfac4..cf2cb92808 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/config/CantonConfig.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/config/CantonConfig.scala @@ -42,6 +42,13 @@ import com.digitalasset.canton.domain.mediator.{ MediatorNodeParameters, RemoteMediatorConfig, } +import com.digitalasset.canton.domain.sequencing.config.{ + RemoteSequencerConfig, + SequencerNodeConfigCommon, + SequencerNodeInitXConfig, + SequencerNodeParameterConfig, + SequencerNodeParameters, +} import com.digitalasset.canton.domain.sequencing.sequencer.* import com.digitalasset.canton.environment.CantonNodeParameters import com.digitalasset.canton.http.{HttpApiConfig, StaticContentConfig, WebsocketConfig} @@ -349,6 +356,7 @@ trait CantonConfig { ParticipantConfigType, ] type MediatorNodeXConfigType <: MediatorNodeConfigCommon + type SequencerNodeXConfigType <: SequencerNodeConfigCommon /** all domains that this Canton process can operate * @@ -384,6 +392,23 @@ trait CantonConfig { n.unwrap -> c } + def sequencersX: Map[InstanceName, SequencerNodeXConfigType] + + /** Use `sequencersX` instead! + */ + def sequencersByStringX: Map[String, SequencerNodeXConfigType] = sequencersX.map { case (n, c) => + n.unwrap -> c + } + + def remoteSequencersX: Map[InstanceName, RemoteSequencerConfig] + + /** Use `remoteSequencersX` instead! + */ + def remoteSequencersByStringX: Map[String, RemoteSequencerConfig] = remoteSequencersX.map { + case (n, c) => + n.unwrap -> c + } + def mediatorsX: Map[InstanceName, MediatorNodeXConfigType] /** Use `mediatorsX` instead! @@ -499,6 +524,21 @@ trait CantonConfig { InstanceName.tryCreate(name) ) + private lazy val sequencerNodeParametersX_ : Map[InstanceName, SequencerNodeParameters] = + sequencersX.fmap { sequencerNodeXConfig => + SequencerNodeParameters( + general = CantonNodeParameterConverter.general(this, sequencerNodeXConfig), + protocol = CantonNodeParameterConverter.protocol(this, sequencerNodeXConfig.parameters), + maxBurstFactor = sequencerNodeXConfig.parameters.maxBurstFactor, + ) + } + + private[canton] def sequencerNodeParametersX(name: InstanceName): SequencerNodeParameters = + nodeParametersFor(sequencerNodeParametersX_, "sequencer-x", name) + + private[canton] def sequencerNodeParametersByStringX(name: String): SequencerNodeParameters = + sequencerNodeParametersX(InstanceName.tryCreate(name)) + private lazy val mediatorNodeParametersX_ : Map[InstanceName, MediatorNodeParameters] = mediatorsX.fmap { mediatorNodeConfig => MediatorNodeParameters( @@ -800,9 +840,6 @@ object CantonConfig { deriveReader[CryptoSchemeConfig[S]] lazy implicit val communityCryptoReader: ConfigReader[CommunityCryptoConfig] = deriveReader[CommunityCryptoConfig] - lazy implicit val apiTypeGrpcConfigReader: ConfigReader[ApiType.Grpc.type] = - deriveReader[ApiType.Grpc.type] - lazy implicit val apiTypeConfigReader: ConfigReader[ApiType] = deriveReader[ApiType] lazy implicit val clientConfigReader: ConfigReader[ClientConfig] = deriveReader[ClientConfig] lazy implicit val remoteDomainConfigReader: ConfigReader[RemoteDomainConfig] = deriveReader[RemoteDomainConfig] @@ -935,8 +972,22 @@ object CantonConfig { lazy implicit val communityNewDatabaseSequencerWriterConfigLowLatencyReader : ConfigReader[SequencerWriterConfig.LowLatency] = deriveReader[SequencerWriterConfig.LowLatency] + lazy implicit val sequencerNodeInitXConfigReader: ConfigReader[SequencerNodeInitXConfig] = + deriveReader[SequencerNodeInitXConfig] + .enableNestedOpt("auto-init", _.copy(identity = None)) lazy implicit val communitySequencerConfigReader: ConfigReader[CommunitySequencerConfig] = deriveReader[CommunitySequencerConfig] + lazy implicit val sequencerNodeParametersConfigReader + : ConfigReader[SequencerNodeParameterConfig] = + deriveReader[SequencerNodeParameterConfig] + lazy implicit val SequencerHealthConfigReader: ConfigReader[SequencerHealthConfig] = + deriveReader[SequencerHealthConfig] + lazy implicit val remoteSequencerConfigGrpcReader: ConfigReader[RemoteSequencerConfig.Grpc] = + deriveReader[RemoteSequencerConfig.Grpc] + lazy implicit val remoteSequencerConfigReader: ConfigReader[RemoteSequencerConfig] = + deriveReader[RemoteSequencerConfig] + // since the big majority of users will use GRPC, default to it so that they don't need to specify `type = grpc` + .orElse(ConfigReader[RemoteSequencerConfig.Grpc]) lazy implicit val mediatorNodeParameterConfigReader: ConfigReader[MediatorNodeParameterConfig] = deriveReader[MediatorNodeParameterConfig] lazy implicit val remoteMediatorConfigReader: ConfigReader[RemoteMediatorConfig] = @@ -1216,9 +1267,6 @@ object CantonConfig { deriveWriter[CommunityAdminServerConfig] lazy implicit val tlsBaseServerConfigWriter: ConfigWriter[TlsBaseServerConfig] = deriveWriter[TlsBaseServerConfig] - lazy implicit val apiTypeGrpcConfigWriter: ConfigWriter[ApiType.Grpc.type] = - deriveWriter[ApiType.Grpc.type] - lazy implicit val apiTypeConfigWriter: ConfigWriter[ApiType] = deriveWriter[ApiType] lazy implicit val communityPublicServerConfigWriter: ConfigWriter[CommunityPublicServerConfig] = deriveWriter[CommunityPublicServerConfig] lazy implicit val clockConfigRemoteClockWriter: ConfigWriter[ClockConfig.RemoteClock] = @@ -1314,8 +1362,19 @@ object CantonConfig { lazy implicit val communityDatabaseSequencerWriterConfigLowLatencyWriter : ConfigWriter[SequencerWriterConfig.LowLatency] = deriveWriter[SequencerWriterConfig.LowLatency] + lazy implicit val sequencerNodeInitXConfigWriter: ConfigWriter[SequencerNodeInitXConfig] = + deriveWriter[SequencerNodeInitXConfig] lazy implicit val communitySequencerConfigWriter: ConfigWriter[CommunitySequencerConfig] = deriveWriter[CommunitySequencerConfig] + lazy implicit val sequencerNodeParameterConfigWriter + : ConfigWriter[SequencerNodeParameterConfig] = + deriveWriter[SequencerNodeParameterConfig] + lazy implicit val SequencerHealthConfigWriter: ConfigWriter[SequencerHealthConfig] = + deriveWriter[SequencerHealthConfig] + lazy implicit val remoteSequencerConfigGrpcWriter: ConfigWriter[RemoteSequencerConfig.Grpc] = + deriveWriter[RemoteSequencerConfig.Grpc] + lazy implicit val remoteSequencerConfigWriter: ConfigWriter[RemoteSequencerConfig] = + deriveWriter[RemoteSequencerConfig] lazy implicit val mediatorNodeParameterConfigWriter: ConfigWriter[MediatorNodeParameterConfig] = deriveWriter[MediatorNodeParameterConfig] lazy implicit val remoteMediatorConfigWriter: ConfigWriter[RemoteMediatorConfig] = diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/config/CommunityConfigValidations.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/config/CommunityConfigValidations.scala index 00718aaa14..093793c9e1 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/config/CommunityConfigValidations.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/config/CommunityConfigValidations.scala @@ -154,10 +154,12 @@ object CommunityConfigValidations domains, participants, participantsX, + sequencersX, mediatorsX, remoteDomains, remoteParticipants, remoteParticipantsX, + remoteSequencersX, remoteMediatorsX, _, _, @@ -174,6 +176,8 @@ object CommunityConfigValidations remoteParticipantsX, mediatorsX, remoteMediatorsX, + sequencersX, + remoteSequencersX, ) .exists(_.nonEmpty), (), @@ -182,9 +186,6 @@ object CommunityConfigValidations } - private[config] val backwardsCompatibleLoggingConfigErr = - "Inconsistent configuration of canton.monitoring.log-message-payloads and canton.monitoring.logging.api.message-payloads. Please use the latter in your configuration" - private def developmentProtocolSafetyCheckDomains( config: CantonConfig ): Validated[NonEmpty[Seq[String]], Unit] = { diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/ConsoleEnvironment.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/ConsoleEnvironment.scala index f1bd4432b7..0747798341 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/ConsoleEnvironment.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/ConsoleEnvironment.scala @@ -26,6 +26,7 @@ import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.environment.Environment import com.digitalasset.canton.lifecycle.{FlagCloseable, Lifecycle} import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} +import com.digitalasset.canton.participant.ParticipantNodeCommon import com.digitalasset.canton.protocol.SerializableContract import com.digitalasset.canton.sequencing.{ GrpcSequencerConnection, @@ -346,6 +347,16 @@ trait ConsoleEnvironment extends NamedLogging with FlagCloseable with NoTracing environment.config.remoteDomainsByString.keys.map(createRemoteDomainReference).toSeq, ) + lazy val sequencersX: NodeReferences[ + SequencerNodeReferenceX, + RemoteSequencerNodeReferenceX, + LocalSequencerNodeReferenceX, + ] = + NodeReferences( + environment.config.sequencersByStringX.keys.map(createSequencerReferenceX).toSeq, + environment.config.remoteSequencersByStringX.keys.map(createRemoteSequencerReferenceX).toSeq, + ) + lazy val mediatorsX : NodeReferences[MediatorReferenceX, RemoteMediatorReferenceX, LocalMediatorReferenceX] = NodeReferences( @@ -367,8 +378,20 @@ trait ConsoleEnvironment extends NamedLogging with FlagCloseable with NoTracing LocalInstanceReferenceCommon, ] = { NodeReferences( - mergeLocalInstances(participants.local, participantsX.local, domains.local), - mergeRemoteInstances(participants.remote, participantsX.remote, domains.remote), + mergeLocalInstances( + participants.local, + participantsX.local, + domains.local, + sequencersX.local, + mediatorsX.local, + ), + mergeRemoteInstances( + participants.remote, + participantsX.remote, + domains.remote, + sequencersX.remote, + mediatorsX.remote, + ), ) } @@ -430,6 +453,22 @@ trait ConsoleEnvironment extends NamedLogging with FlagCloseable with NoTracing d, ) ) + val localMediatorXBinds: Seq[TopLevelValue[_]] = + mediatorsX.local.map(d => + TopLevelValue(d.name, helpText("local mediator-x", d.name), d, nodeTopic) + ) + val remoteMediatorXBinds: Seq[TopLevelValue[_]] = + mediatorsX.remote.map(d => + TopLevelValue(d.name, helpText("remote mediator-x", d.name), d, nodeTopic) + ) + val localSequencerXBinds: Seq[TopLevelValue[_]] = + sequencersX.local.map(d => + TopLevelValue(d.name, helpText("local sequencer-x", d.name), d, nodeTopic) + ) + val remoteSequencerXBinds: Seq[TopLevelValue[_]] = + sequencersX.remote.map(d => + TopLevelValue(d.name, helpText("remote sequencer-x", d.name), d, nodeTopic) + ) val clockBinds: Option[TopLevelValue[_]] = environment.simClock.map(cl => TopLevelValue("clock", "Simulated time", new SimClockCommand(cl)) @@ -437,7 +476,7 @@ trait ConsoleEnvironment extends NamedLogging with FlagCloseable with NoTracing val referencesTopic = Seq(topicGenericNodeReferences) localParticipantBinds ++ remoteParticipantBinds ++ localParticipantXBinds ++ remoteParticipantXBinds ++ - localDomainBinds ++ remoteDomainBinds ++ clockBinds.toList :+ + localDomainBinds ++ remoteDomainBinds ++ localSequencerXBinds ++ remoteSequencerXBinds ++ localMediatorXBinds ++ remoteMediatorXBinds ++ clockBinds.toList :+ TopLevelValue( "participants", "All participant nodes" + genericNodeReferencesDoc, @@ -455,6 +494,18 @@ trait ConsoleEnvironment extends NamedLogging with FlagCloseable with NoTracing .Partial("domains", "All domain nodes" + genericNodeReferencesDoc, referencesTopic), domains, ) :+ + TopLevelValue( + "mediatorsX", + "All mediator-x nodes" + genericNodeReferencesDoc, + mediatorsX, + referencesTopic, + ) :+ + TopLevelValue( + "sequencersX", + "All sequencer-x nodes" + genericNodeReferencesDoc, + sequencersX, + referencesTopic, + ) :+ TopLevelValue("nodes", "All nodes" + genericNodeReferencesDoc, nodes, referencesTopic) } @@ -497,6 +548,12 @@ trait ConsoleEnvironment extends NamedLogging with FlagCloseable with NoTracing protected def createDomainReference(name: String): DomainLocalRef protected def createRemoteDomainReference(name: String): DomainRemoteRef + private def createSequencerReferenceX(name: String): LocalSequencerNodeReferenceX = + new LocalSequencerNodeReferenceX(this, name) + + private def createRemoteSequencerReferenceX(name: String): RemoteSequencerNodeReferenceX = + new RemoteSequencerNodeReferenceX(this, name) + private def createMediatorReferenceX(name: String): LocalMediatorReferenceX = new LocalMediatorReferenceX(this, name) @@ -551,11 +608,13 @@ object ConsoleEnvironment { ): ParticipantReferencesExtensions = new ParticipantReferencesExtensions(participants) - implicit def toLocalParticipantReferencesExtensions( - participants: Seq[LocalParticipantReferenceCommon] + implicit def toLocalParticipantReferencesExtensions[ParticipantNodeT <: ParticipantNodeCommon]( + participants: Seq[LocalParticipantReferenceCommon[ParticipantNodeT]] )(implicit consoleEnvironment: ConsoleEnvironment - ): LocalParticipantReferencesExtensions[LocalParticipantReferenceCommon] = + ): LocalParticipantReferencesExtensions[ParticipantNodeT, LocalParticipantReferenceCommon[ + ParticipantNodeT + ]] = new LocalParticipantReferencesExtensions(participants) /** Implicitly map strings to DomainAlias, Fingerprint and Identifier diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/ConsoleMacros.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/ConsoleMacros.scala index 4a2f3994e7..0bfc51b5e1 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/ConsoleMacros.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/ConsoleMacros.scala @@ -6,6 +6,7 @@ package com.digitalasset.canton.console import better.files.File import cats.syntax.either.* import cats.syntax.functor.* +import cats.syntax.functorFilter.* import ch.qos.logback.classic.spi.ILoggingEvent import ch.qos.logback.classic.{Level, Logger} import ch.qos.logback.core.spi.AppenderAttachable @@ -22,8 +23,10 @@ import com.daml.ledger.api.v1.value.{ Value, } import com.daml.lf.value.Value.ContractId -import com.digitalasset.canton.DomainAlias +import com.daml.nonempty.NonEmpty +import com.daml.nonempty.NonEmptyReturningOps.* import com.digitalasset.canton.admin.api.client.commands.LedgerApiTypeWrappers.ContractData +import com.digitalasset.canton.admin.api.client.data import com.digitalasset.canton.admin.api.client.data.{ListPartiesResult, TemplateId} import com.digitalasset.canton.concurrent.Threading import com.digitalasset.canton.config.NonNegativeDuration @@ -38,10 +41,24 @@ import com.digitalasset.canton.participant.config.{AuthServiceConfig, BasePartic import com.digitalasset.canton.participant.ledger.api.JwtTokenUtilities import com.digitalasset.canton.protocol.SerializableContract.LedgerCreateTime import com.digitalasset.canton.protocol.* +import com.digitalasset.canton.sequencing.SequencerConnections import com.digitalasset.canton.topology.* +import com.digitalasset.canton.topology.store.TopologyStoreId.AuthorizedStore +import com.digitalasset.canton.topology.transaction.SignedTopologyTransactionX.{ + GenericSignedTopologyTransactionX, + PositiveSignedTopologyTransactionX, +} +import com.digitalasset.canton.topology.transaction.{ + DecentralizedNamespaceDefinitionX, + NamespaceDelegationX, + OwnerToKeyMappingX, + SignedTopologyTransactionX, + TopologyChangeOpX, +} import com.digitalasset.canton.tracing.{NoTracing, TraceContext} -import com.digitalasset.canton.util.BinaryFileUtil +import com.digitalasset.canton.util.{BinaryFileUtil, EitherUtil} import com.digitalasset.canton.version.ProtocolVersion +import com.digitalasset.canton.{DomainAlias, SequencerAlias} import com.google.protobuf.ByteString import com.typesafe.scalalogging.LazyLogging import io.circe.Encoder @@ -751,6 +768,217 @@ trait ConsoleMacros extends NamedLogging with NoTracing { } } + @Help.Summary("Functions to bootstrap/setup decentralized namespaces or full domains") + @Help.Group("Bootstrap") + object bootstrap extends Helpful { + + @Help.Summary("Bootstraps a decentralized namespace for the provided owners") + @Help.Description( + """Returns the decentralized namespace, the fully authorized transaction of its definition, as well + |as all root certificates of the owners. This allows other nodes to import and + |fully validate the decentralized namespace definition. + |After this call has finished successfully, all of the owners have stored the co-owners' identity topology + |transactions as well as the fully authorized decentralized namespace definition in the specified topology store.""" + ) + def decentralized_namespace( + owners: Seq[InstanceReferenceX], + store: String = AuthorizedStore.filterName, + ): (Namespace, Seq[GenericSignedTopologyTransactionX]) = { + val decentralizedNamespaceDefinition = NonEmpty + .from(owners) + .getOrElse( + throw new IllegalArgumentException( + "There must be at least 1 owner for a decentralizedNamespace." + ) + ) + .map( + _.topology.decentralized_namespaces.propose( + owners.map(_.id.member.uid.namespace).toSet, + PositiveInt.tryCreate(1.max(owners.size - 1)), + store = store, + ) + ) + // merging the signatures here is an "optimization" so that later we only upload a single + // decentralizedNamespace transaction, instead of a transaction per owner. + .reduceLeft[SignedTopologyTransactionX[ + TopologyChangeOpX, + DecentralizedNamespaceDefinitionX, + ]]((txA, txB) => txA.addSignatures(txB.signatures.forgetNE.toSeq)) + + val ownerNSDs = owners.flatMap(_.topology.transactions.identity_transactions()) + val foundingTransactions = ownerNSDs :+ decentralizedNamespaceDefinition + + owners.foreach(_.topology.transactions.load(foundingTransactions, store = store)) + + (decentralizedNamespaceDefinition.transaction.mapping.namespace, foundingTransactions) + } + + private def expected_namespace( + owners: NonEmpty[Set[InstanceReferenceX]] + ): Either[String, Option[Namespace]] = { + val expectedNamespace = + DecentralizedNamespaceDefinitionX.computeNamespace( + owners.forgetNE.map(_.id.member.uid.namespace) + ) + val recordedNamespaces = + owners.map( + _.topology.decentralized_namespaces + .list(filterStore = AuthorizedStore.filterName) + .collectFirst { + case result if result.item.namespace == expectedNamespace => result.item.namespace + } + ) + Either.cond( + recordedNamespaces.sizeIs == 1, + recordedNamespaces.head1, + "the domain owners do not agree on the decentralizedNamespace", + ) + } + + private def in_domain( + sequencers: NonEmpty[Set[SequencerNodeReferenceX]], + mediators: NonEmpty[Set[MediatorReferenceX]], + )(domainId: DomainId): Either[String, Unit] = + EitherUtil.condUnitE( + sequencers.forall(_.health.status.successOption.exists(_.domainId == domainId)) && + mediators.forall(_.health.status.successOption.exists(_.domainId == domainId)), + "the domain has already been bootstrapped but not all the given sequencers and mediators are in it", + ) + + private def no_domain(nodes: NonEmpty[Set[InstanceReferenceX]]): Either[String, Unit] = + EitherUtil.condUnitE( + !nodes.exists(_.health.initialized()), + "the domain has not yet been bootstrapped but some sequencers or mediators are already part of one", + ) + + private def check_domain_bootstrap_status( + name: String, + owners: Seq[InstanceReferenceX], + sequencers: Seq[SequencerNodeReferenceX], + mediators: Seq[MediatorReferenceX], + ): Either[String, Option[DomainId]] = + for { + neOwners <- NonEmpty.from(owners.toSet).toRight("you need at least one domain owner") + neSequencers <- NonEmpty.from(sequencers.toSet).toRight("you need at least one sequencer") + neMediators <- NonEmpty.from(mediators.toSet).toRight("you need at least one mediator") + nodes = neOwners ++ neSequencers ++ neMediators + _ = EitherUtil.condUnitE(nodes.forall(_.health.running()), "all nodes must be running") + ns <- expected_namespace(neOwners) + id = ns.map(ns => DomainId(UniqueIdentifier.tryCreate(name, ns.toProtoPrimitive))) + _ <- id.fold(no_domain(neSequencers ++ neMediators))(in_domain(neSequencers, neMediators)) + } yield id + + private def run_bootstrap( + domainName: String, + staticDomainParameters: data.StaticDomainParameters, + domainOwners: Seq[InstanceReferenceX], + sequencers: Seq[SequencerNodeReferenceX], + mediators: Seq[MediatorReferenceX], + ): DomainId = { + val (decentralizedNamespace, foundingTxs) = + bootstrap.decentralized_namespace(domainOwners, store = AuthorizedStore.filterName) + + val domainId = DomainId( + UniqueIdentifier.tryCreate(domainName, decentralizedNamespace.toProtoPrimitive) + ) + + val seqMedIdentityTxs = + (sequencers ++ mediators).flatMap(_.topology.transactions.identity_transactions()) + domainOwners.foreach( + _.topology.transactions.load(seqMedIdentityTxs, store = AuthorizedStore.filterName) + ) + + val domainGenesisTxs = domainOwners.flatMap( + _.topology.domain_bootstrap.generate_genesis_topology( + domainId, + domainOwners.map(_.id.member), + sequencers.map(_.id), + mediators.map(_.id), + ) + ) + + val initialTopologyState = (foundingTxs ++ seqMedIdentityTxs ++ domainGenesisTxs) + .mapFilter(_.selectOp[TopologyChangeOpX.Replace]) + + // TODO(#12390) replace this merge / active with proper tooling and checks that things are really fully authorized + val orderingMap = + Seq( + NamespaceDelegationX.code, + OwnerToKeyMappingX.code, + DecentralizedNamespaceDefinitionX.code, + ).zipWithIndex.toMap + .withDefaultValue(5) + + val merged = initialTopologyState + .groupBy1(_.transaction.hash) + .values + .map( + // combine signatures of transactions with the same hash + _.reduceLeft[PositiveSignedTopologyTransactionX] { (a, b) => + a.addSignatures(b.signatures.toSeq) + }.copy(isProposal = false) + ) + .toSeq + .sortBy(tx => orderingMap(tx.transaction.mapping.code)) + + // TODO(#14075) resolve with raf on what to use here (right now we use internal case structure) + sequencers.foreach(_.setup.assign_from_beginning(merged, staticDomainParameters).discard) + + mediators.foreach { mediator => + mediator.setup + .assign( + domainId, + staticDomainParameters, + SequencerConnections.tryMany( + sequencers.map(s => + s.sequencerConnection.withAlias(SequencerAlias.tryCreate(s.name)) + ), + PositiveInt.tryCreate(1), + ), + ) + } + + domainId + } + + @Help.Summary( + """Bootstraps a new domain with the given static domain parameters and members. Any participants as domain owners + |must still manually connect to the domain afterwards.""" + ) + def domain( + domainName: String, + sequencers: Seq[SequencerNodeReferenceX], + mediators: Seq[MediatorReferenceX], + domainOwners: Seq[InstanceReferenceX] = Seq.empty, + staticDomainParameters: data.StaticDomainParameters = + data.StaticDomainParameters.defaultsWithoutKMS, + ): DomainId = { + val domainOwnersOrDefault = if (domainOwners.isEmpty) sequencers else domainOwners + check_domain_bootstrap_status( + domainName, + domainOwnersOrDefault, + sequencers, + mediators, + ) match { + case Right(Some(domainId)) => + logger.info(s"Domain $domainName has already been bootstrapped with ID $domainId") + domainId + case Right(None) => + run_bootstrap( + domainName, + staticDomainParameters, + domainOwnersOrDefault, + sequencers, + mediators, + ) + case Left(error) => + val message = s"The domain cannot be bootstrapped: $error" + logger.error(message) + sys.error(message) + } + } + } + } object ConsoleMacros extends ConsoleMacros with NamedLogging { diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/InstanceReference.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/InstanceReference.scala index 6b499b8fc6..3b60d10838 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/InstanceReference.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/InstanceReference.scala @@ -3,12 +3,15 @@ package com.digitalasset.canton.console +import cats.syntax.either.* import com.digitalasset.canton.* import com.digitalasset.canton.admin.api.client.commands.GrpcAdminCommand -import com.digitalasset.canton.config.RequireTypes.Port +import com.digitalasset.canton.admin.api.client.data.StaticDomainParameters as ConsoleStaticDomainParameters +import com.digitalasset.canton.common.domain.grpc.GrpcSequencerConnectClient +import com.digitalasset.canton.config.RequireTypes.{NonNegativeInt, Port, PositiveInt} import com.digitalasset.canton.config.* import com.digitalasset.canton.console.CommandErrors.NodeNotStarted -import com.digitalasset.canton.console.commands.* +import com.digitalasset.canton.console.commands.{SequencerNodeAdministrationGroupXWithInit, *} import com.digitalasset.canton.crypto.Crypto import com.digitalasset.canton.domain.config.RemoteDomainConfig import com.digitalasset.canton.domain.mediator.{ @@ -17,6 +20,11 @@ import com.digitalasset.canton.domain.mediator.{ MediatorNodeX, RemoteMediatorConfig, } +import com.digitalasset.canton.domain.sequencing.config.{ + RemoteSequencerConfig, + SequencerNodeConfigCommon, +} +import com.digitalasset.canton.domain.sequencing.{SequencerNodeBootstrapX, SequencerNodeX} import com.digitalasset.canton.domain.{Domain, DomainNodeBootstrap} import com.digitalasset.canton.environment.* import com.digitalasset.canton.health.admin.data.{ @@ -24,6 +32,7 @@ import com.digitalasset.canton.health.admin.data.{ MediatorNodeStatus, NodeStatus, ParticipantStatus, + SequencerNodeStatus, } import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging, TracedLogger} @@ -40,11 +49,19 @@ import com.digitalasset.canton.participant.{ ParticipantNodeX, } import com.digitalasset.canton.sequencing.{GrpcSequencerConnection, SequencerConnections} -import com.digitalasset.canton.topology.{DomainId, MediatorId, NodeIdentity, ParticipantId} -import com.digitalasset.canton.tracing.NoTracing +import com.digitalasset.canton.topology.{ + DomainId, + MediatorId, + Member, + NodeIdentity, + ParticipantId, + SequencerId, +} +import com.digitalasset.canton.tracing.{NoTracing, TraceContext, TracingConfig} import com.digitalasset.canton.util.ErrorUtil -import scala.concurrent.{ExecutionContext, TimeoutException} +import java.util.concurrent.atomic.AtomicReference +import scala.concurrent.{Await, ExecutionContext, TimeoutException} import scala.util.hashing.MurmurHash3 trait InstanceReferenceCommon @@ -202,7 +219,7 @@ trait LocalInstanceReferenceCommon extends InstanceReferenceCommon with NoTracin ErrorUtil.withThrowableLogging(clear_cache()) } - protected def migrateInstanceDb(): Either[StartupError, _] = nodes.migrateDatabase(name) + protected def migrateInstanceDb(): Either[StartupError, ?] = nodes.migrateDatabase(name) protected def repairMigrationOfInstance(force: Boolean): Either[StartupError, Unit] = { Either .cond(force, (), DidntUseForceOnRepairMigration(name)) @@ -223,7 +240,7 @@ trait LocalInstanceReferenceCommon extends InstanceReferenceCommon with NoTracin NodeNotStarted.ErrorCanton(this) override protected[console] def adminCommand[Result]( - grpcCommand: GrpcAdminCommand[_, _, Result] + grpcCommand: GrpcAdminCommand[?, ?, Result] ): ConsoleCommandResult[Result] = { runCommandIfRunning( consoleEnvironment.grpcAdminCommandRunner @@ -248,7 +265,7 @@ trait GrpcRemoteInstanceReference extends RemoteInstanceReference { def config: NodeConfig override protected[console] def adminCommand[Result]( - grpcCommand: GrpcAdminCommand[_, _, Result] + grpcCommand: GrpcAdminCommand[?, ?, Result] ): ConsoleCommandResult[Result] = consoleEnvironment.grpcAdminCommandRunner.runCommand( name, @@ -439,7 +456,7 @@ class ExternalLedgerApiClient( throw new NotImplementedError("domain_of is not implemented for external ledger api clients") override protected[console] def ledgerApiCommand[Result]( - command: GrpcAdminCommand[_, _, Result] + command: GrpcAdminCommand[?, ?, Result] ): ConsoleCommandResult[Result] = consoleEnvironment.grpcAdminCommandRunner .runCommand("sourceLedger", command, ClientConfig(hostname, port, tls), token) @@ -454,7 +471,10 @@ class ExternalLedgerApiClient( object ExternalLedgerApiClient { - def forReference(participant: LocalParticipantReferenceCommon, token: String)(implicit + def forReference[ParticipantNodeT <: ParticipantNodeCommon]( + participant: LocalParticipantReferenceCommon[ParticipantNodeT], + token: String, + )(implicit env: ConsoleEnvironment ): ExternalLedgerApiClient = { val cc = participant.config.ledgerApi.clientConfig @@ -629,7 +649,7 @@ sealed trait RemoteParticipantReferenceCommon def config: RemoteParticipantConfig override protected[console] def ledgerApiCommand[Result]( - command: GrpcAdminCommand[_, _, Result] + command: GrpcAdminCommand[?, ?, Result] ): ConsoleCommandResult[Result] = consoleEnvironment.grpcAdminCommandRunner.runCommand( name, @@ -680,17 +700,20 @@ class RemoteParticipantReference(environment: ConsoleEnvironment, override val n } -sealed trait LocalParticipantReferenceCommon +sealed trait LocalParticipantReferenceCommon[ParticipantNodeT <: ParticipantNodeCommon] extends LedgerApiCommandRunner with ParticipantReferenceCommon - with LocalInstanceReferenceCommon { + with LocalInstanceReferenceCommon + with BaseInspection[ParticipantNodeT] { + + override val name: String def config: LocalParticipantConfig def adminToken: Option[String] override protected[console] def ledgerApiCommand[Result]( - command: GrpcAdminCommand[_, _, Result] + command: GrpcAdminCommand[?, ?, Result] ): ConsoleCommandResult[Result] = runCommandIfRunning( consoleEnvironment.grpcAdminCommandRunner @@ -712,9 +735,8 @@ class LocalParticipantReference( override val consoleEnvironment: ConsoleEnvironment, name: String, ) extends ParticipantReference(consoleEnvironment, name) - with LocalParticipantReferenceCommon - with LocalInstanceReference - with BaseInspection[ParticipantNode] { + with LocalParticipantReferenceCommon[ParticipantNode] + with LocalInstanceReference { override private[console] val nodes = consoleEnvironment.environment.participants @@ -885,9 +907,8 @@ class LocalParticipantReferenceX( override val consoleEnvironment: ConsoleEnvironment, name: String, ) extends ParticipantReferenceX(consoleEnvironment, name) - with LocalParticipantReferenceCommon - with LocalInstanceReferenceX - with BaseInspection[ParticipantNodeX] { + with LocalParticipantReferenceCommon[ParticipantNodeX] + with LocalInstanceReferenceX { override private[console] val nodes = consoleEnvironment.environment.participantsX @@ -936,6 +957,374 @@ class LocalParticipantReferenceX( def repair: LocalParticipantRepairAdministration = repair_ } +trait SequencerNodeReferenceCommon + extends InstanceReferenceCommon + with InstanceReferenceWithSequencerConnection { + + override type Status = SequencerNodeStatus + + @Help.Summary( + "Yields the globally unique id of this sequencer. " + + "Throws an exception, if the id has not yet been allocated (e.g., the sequencer has not yet been started)." + ) + def id: SequencerId = topology.idHelper(SequencerId(_)) + +} + +object SequencerNodeReference { + val InstanceType = "Sequencer" +} + +abstract class SequencerNodeReference( + val consoleEnvironment: ConsoleEnvironment, + name: String, +) extends SequencerNodeReferenceCommon + with InstanceReferenceWithSequencer + with InstanceReference + with SequencerNodeAdministration { + + override def equals(obj: Any): Boolean = { + obj match { + case x: SequencerNodeReference => x.consoleEnvironment == consoleEnvironment && x.name == name + case _ => false + } + } + + override protected val instanceType: String = SequencerNodeReference.InstanceType + override protected val loggerFactory: NamedLoggerFactory = + consoleEnvironment.environment.loggerFactory.append("sequencer", name) + + private lazy val parties_ = new PartiesAdministrationGroup(this, consoleEnvironment) + + override def parties: PartiesAdministrationGroup = parties_ + + private lazy val topology_ = + new TopologyAdministrationGroup( + this, + health.status.successOption.map(_.topologyQueue), + consoleEnvironment, + loggerFactory, + ) + + override def topology: TopologyAdministrationGroup = topology_ + + private lazy val sequencer_ = + new SequencerAdministrationGroup(this, consoleEnvironment, loggerFactory) + + @Help.Summary("Manage the sequencer") + @Help.Group("Sequencer") + override def sequencer: SequencerAdministrationGroup = sequencer_ + + @Help.Summary("Health and diagnostic related commands") + @Help.Group("Health") + override def health = + new HealthAdministration[SequencerNodeStatus]( + this, + consoleEnvironment, + SequencerNodeStatus.fromProtoV0, + ) + +} + +object SequencerNodeReferenceX { + val InstanceType = "SequencerX" +} + +abstract class SequencerNodeReferenceX( + val consoleEnvironment: ConsoleEnvironment, + name: String, +) extends SequencerNodeReferenceCommon + with InstanceReferenceX + with SequencerNodeAdministrationGroupXWithInit { + self => + + override protected def runner: AdminCommandRunner = this + + override protected def disable_member(member: Member): Unit = + repair.disable_member(member) + + override def equals(obj: Any): Boolean = { + obj match { + case x: SequencerNodeReferenceX => + x.consoleEnvironment == consoleEnvironment && x.name == name + case _ => false + } + } + + override protected val instanceType: String = SequencerNodeReferenceX.InstanceType + override protected val loggerFactory: NamedLoggerFactory = + consoleEnvironment.environment.loggerFactory.append("sequencerx", name) + + private lazy val topology_ = + new TopologyAdministrationGroupX( + this, + health.status.successOption.map(_.topologyQueue), + consoleEnvironment, + loggerFactory, + ) + + private lazy val grpcSequencerConnectClient = new GrpcSequencerConnectClient( + sequencerConnection = sequencerConnection, + timeouts = ProcessingTimeout(), + traceContextPropagation = TracingConfig.Propagation.Enabled, + loggerFactory = loggerFactory, + )(consoleEnvironment.environment.executionContext) + + override def topology: TopologyAdministrationGroupX = topology_ + + private lazy val parties_ = new PartiesAdministrationGroupX(this, consoleEnvironment) + + override def parties: PartiesAdministrationGroupX = parties_ + + private val staticDomainParameters: AtomicReference[Option[ConsoleStaticDomainParameters]] = + new AtomicReference[Option[ConsoleStaticDomainParameters]](None) + + private val domainId: AtomicReference[Option[DomainId]] = + new AtomicReference[Option[DomainId]](None) + + @Help.Summary("Health and diagnostic related commands") + @Help.Group("Health") + override def health = + new HealthAdministrationX[SequencerNodeStatus]( + this, + consoleEnvironment, + SequencerNodeStatus.fromProtoV0, + ) + + private lazy val sequencerXTrafficControl = new TrafficControlSequencerAdministrationGroup( + this, + topology, + this, + consoleEnvironment, + loggerFactory, + ) + + @Help.Summary("Admin traffic control related commands") + @Help.Group("Traffic") + override def traffic_control: TrafficControlSequencerAdministrationGroup = + sequencerXTrafficControl + + @Help.Summary("Return domain id of the domain") + def domain_id: DomainId = { + domainId.get() match { + case Some(id) => id + case None => + val id = TraceContext.withNewTraceContext { implicit traceContext => + Await + .result( + grpcSequencerConnectClient.getDomainId(name).value, + consoleEnvironment.commandTimeouts.bounded.duration, + ) + .valueOr(_ => throw new CommandFailure()) + } + + domainId.set(Some(id)) + id + } + } + + object mediators { + object groups { + @Help.Summary("Propose a new mediator group") + @Help.Description(""" + group: the mediator group identifier + threshold: the minimum number of mediators that need to come to a consensus for a message to be sent to other members. + active: the list of mediators that will take part in the mediator consensus in this mediator group + observers: the mediators that will receive all messages but will not participate in mediator consensus + """) + def propose_new_group( + group: NonNegativeInt, + threshold: PositiveInt, + active: Seq[MediatorReferenceX], + observers: Seq[MediatorReferenceX] = Nil, + ): Unit = { + + val domainId = domain_id + val staticDomainParameters = domain_parameters.static.get() + + val mediators = active ++ observers + + mediators.foreach { mediator => + val identityState = mediator.topology.transactions.identity_transactions() + + topology.transactions.load(identityState, domainId.filterString) + } + + topology.mediators + .propose( + domainId = domainId, + threshold = threshold, + active = active.map(_.id), + observers = observers.map(_.id), + group = group, + ) + .discard + + mediators.foreach( + _.setup.assign( + domainId, + staticDomainParameters, + SequencerConnections.single(sequencerConnection), + ) + ) + } + + @Help.Summary("Propose an update to a mediator group") + @Help.Description(""" + group: the mediator group identifier + threshold: the minimum number of mediators that need to come to a consensus for a message to be sent to other members. + additionalActive: the new mediators that will take part in the mediator consensus in this mediator group + additionalObservers: the new mediators that will receive all messages but will not participate in mediator consensus + """) + def propose_delta( + group: NonNegativeInt, + threshold: PositiveInt, + additionalActive: Seq[MediatorReferenceX], + additionalObservers: Seq[MediatorReferenceX] = Nil, + ): Unit = { + + val staticDomainParameters = domain_parameters.static.get() + val domainId = domain_id + + val currentMediators = topology.mediators + .list(filterStore = domainId.filterString, group = Some(group)) + .maxByOption(_.context.serial) + .getOrElse(throw new IllegalArgumentException(s"Unknown mediator group $group")) + + val currentActive = currentMediators.item.active + val currentObservers = currentMediators.item.observers + val current = currentActive ++ currentObservers + + val newMediators = + (additionalActive ++ additionalObservers).filterNot(m => current.contains(m.id)) + + newMediators.foreach { med => + val identityState = med.topology.transactions.identity_transactions() + + topology.transactions.load(identityState, domainId.filterString) + } + + topology.mediators + .propose( + domainId = domainId, + threshold = threshold, + active = (currentActive ++ additionalActive.map(_.id)).distinct, + observers = (currentObservers ++ additionalObservers.map(_.id)).distinct, + group = group, + ) + .discard + + newMediators.foreach( + _.setup.assign( + domainId, + staticDomainParameters, + SequencerConnections.single(sequencerConnection), + ) + ) + } + } + } + + @Help.Summary("Domain parameters related commands") + @Help.Group("Domain parameters") + object domain_parameters { + object static { + @Help.Summary("Return static domain parameters of the domain") + def get(): ConsoleStaticDomainParameters = { + staticDomainParameters.get() match { + case Some(parameters) => parameters + case None => + val parameters = TraceContext.withNewTraceContext { implicit traceContext => + Await + .result( + grpcSequencerConnectClient.getDomainParameters(name).value, + consoleEnvironment.commandTimeouts.bounded.duration, + ) + .map(ConsoleStaticDomainParameters(_)) + .valueOr(_ => throw new CommandFailure()) + } + + staticDomainParameters.set(Some(parameters)) + parameters + } + } + } + } +} + +trait LocalSequencerNodeReferenceCommon extends LocalInstanceReferenceCommon { + this: SequencerNodeReferenceCommon => + + def config: SequencerNodeConfigCommon + + override lazy val sequencerConnection: GrpcSequencerConnection = + config.publicApi.toSequencerConnectionConfig.toConnection + .fold(err => sys.error(s"Sequencer $name has invalid connection config: $err"), identity) +} + +class LocalSequencerNodeReferenceX( + override val consoleEnvironment: ConsoleEnvironment, + val name: String, +) extends SequencerNodeReferenceX(consoleEnvironment, name) + with LocalSequencerNodeReferenceCommon + with LocalInstanceReferenceX + with BaseInspection[SequencerNodeX] { + + override protected[canton] def executionContext: ExecutionContext = + consoleEnvironment.environment.executionContext + + @Help.Summary("Returns the sequencerx configuration") + override def config: SequencerNodeConfigCommon = + consoleEnvironment.environment.config.sequencersByStringX(name) + + private[console] val nodes: SequencerNodesX[?] = + consoleEnvironment.environment.sequencersX + + override protected[console] def runningNode: Option[SequencerNodeBootstrapX] = + nodes.getRunning(name) + + override protected[console] def startingNode: Option[SequencerNodeBootstrapX] = + nodes.getStarting(name) +} + +trait RemoteSequencerNodeReferenceCommon + extends SequencerNodeReferenceCommon + with RemoteInstanceReference { + def environment: ConsoleEnvironment + + @Help.Summary("Returns the remote sequencer configuration") + def config: RemoteSequencerConfig + + override def sequencerConnection: GrpcSequencerConnection = + config.publicApi.toConnection + .fold(err => sys.error(s"Sequencer $name has invalid connection config: $err"), identity) + + override protected[console] def adminCommand[Result]( + grpcCommand: GrpcAdminCommand[?, ?, Result] + ): ConsoleCommandResult[Result] = + config match { + case config: RemoteSequencerConfig.Grpc => + consoleEnvironment.grpcAdminCommandRunner.runCommand( + name, + grpcCommand, + config.clientAdminApi, + None, + ) + } +} + +class RemoteSequencerNodeReferenceX(val environment: ConsoleEnvironment, val name: String) + extends SequencerNodeReferenceX(environment, name) + with RemoteSequencerNodeReferenceCommon { + + override protected[canton] def executionContext: ExecutionContext = + consoleEnvironment.environment.executionContext + + @Help.Summary("Returns the sequencerx configuration") + override def config: RemoteSequencerConfig = + environment.environment.config.remoteSequencersByStringX(name) +} + trait MediatorReferenceCommon extends InstanceReferenceCommon { @Help.Summary( diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/ParticipantReferencesExtensions.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/ParticipantReferencesExtensions.scala index ade7bc7174..b8c9b8a156 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/ParticipantReferencesExtensions.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/ParticipantReferencesExtensions.scala @@ -8,6 +8,7 @@ import com.daml.nonempty.NonEmpty import com.digitalasset.canton.config.NonNegativeDuration import com.digitalasset.canton.console.commands.ParticipantCommands import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} +import com.digitalasset.canton.participant.ParticipantNodeCommon import com.digitalasset.canton.participant.domain.DomainConnectionConfig import com.digitalasset.canton.{DomainAlias, SequencerAlias} @@ -138,7 +139,10 @@ class ParticipantReferencesExtensions(participants: Seq[ParticipantReferenceComm } -class LocalParticipantReferencesExtensions[LocalParticipantRef <: LocalParticipantReferenceCommon]( +class LocalParticipantReferencesExtensions[ + ParticipantNodeT <: ParticipantNodeCommon, + LocalParticipantRef <: LocalParticipantReferenceCommon[ParticipantNodeT], +]( participants: Seq[LocalParticipantRef] )(implicit override val consoleEnvironment: ConsoleEnvironment diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/LedgerApiAdministration.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/LedgerApiAdministration.scala index 4b8ef75565..4cffae1ff4 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/LedgerApiAdministration.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/LedgerApiAdministration.scala @@ -26,12 +26,14 @@ import com.daml.ledger.api.v1.value.Value import com.daml.ledger.api.v1.{EventQueryServiceOuterClass, ValueOuterClass} import com.daml.ledger.api.v2.event_query_service.GetEventsByContractIdResponse as GetEventsByContractIdResponseV2 import com.daml.ledger.api.v2.participant_offset.ParticipantOffset +import com.daml.ledger.api.v2.reassignment.Reassignment import com.daml.ledger.api.v2.state_service.GetConnectedDomainsResponse import com.daml.ledger.api.v2.transaction.{ Transaction as TransactionV2, TransactionTree as TransactionTreeV2, } import com.daml.ledger.api.v2.transaction_filter.TransactionFilter as TransactionFilterV2 +import com.daml.ledger.javaapi.data.ReassignmentV2 import com.daml.ledger.{api, javaapi as javab} import com.daml.lf.data.Ref import com.daml.metrics.api.MetricHandle.{Histogram, Meter} @@ -105,6 +107,7 @@ import java.util.concurrent.TimeoutException import java.util.concurrent.atomic.AtomicReference import scala.annotation.nowarn import scala.concurrent.{Await, ExecutionContext} +import scala.util.chaining.scalaUtilChainingOps import scala.util.{Failure, Success, Try} trait BaseLedgerApiAdministration extends NoTracing { @@ -425,7 +428,7 @@ trait BaseLedgerApiAdministration extends NoTracing { def submit( actAs: Seq[PartyId], commands: Seq[Command], - domainId: DomainId, + domainId: Option[DomainId] = None, workflowId: String = "", commandId: String = "", // TODO(#15280) This feature wont work after V1 is removed. Also after witness blinding is implemented, the underlying algorith will be broken. Idea: drop this feature and wait explicitly with some additional tooling. @@ -474,7 +477,7 @@ trait BaseLedgerApiAdministration extends NoTracing { def submit_flat( actAs: Seq[PartyId], commands: Seq[Command], - domainId: DomainId, + domainId: Option[DomainId] = None, workflowId: String = "", commandId: String = "", // TODO(#15280) This feature wont work after V1 is removed. Also after witness blinding is implemented, the underlying algorith will be broken. Idea: drop this feature and wait explicitly with some additional tooling. @@ -514,7 +517,7 @@ trait BaseLedgerApiAdministration extends NoTracing { def submit_async( actAs: Seq[PartyId], commands: Seq[Command], - domainId: DomainId, + domainId: Option[DomainId] = None, workflowId: String = "", commandId: String = "", deduplicationPeriod: Option[DeduplicationPeriod] = None, @@ -1518,10 +1521,450 @@ trait BaseLedgerApiAdministration extends NoTracing { }) } - // TODO(#15274) @Help.Summary("Group of commands that utilize java bindings", FeatureFlag.Testing) @Help.Group("Ledger Api (Java bindings)") - object javaapi extends Helpful + object javaapi extends Helpful { + @Help.Summary("Submit commands (Java bindings)", FeatureFlag.Testing) + @Help.Group("Command Submission (Java bindings)") + object commands extends Helpful { + @Help.Summary( + "Submit java codegen commands and wait for the resulting transaction, returning the transaction tree or failing otherwise", + FeatureFlag.Testing, + ) + @Help.Description( + """Submits a command on behalf of the `actAs` parties, waits for the resulting transaction to commit and returns it. + | If the timeout is set, it also waits for the transaction to appear at all other configured + | participants who were involved in the transaction. The call blocks until the transaction commits or fails; + | the timeout only specifies how long to wait at the other participants. + | Fails if the transaction doesn't commit, or if it doesn't become visible to the involved participants in + | the allotted time. + | Note that if the optTimeout is set and the involved parties are concurrently enabled/disabled or their + | participants are connected/disconnected, the command may currently result in spurious timeouts or may + | return before the transaction appears at all the involved participants.""" + ) + def submit( + actAs: Seq[PartyId], + commands: Seq[javab.data.Command], + domainId: Option[DomainId] = None, + workflowId: String = "", + commandId: String = "", + // TODO(#15280) This feature wont work after V1 is removed. Also after witness blinding is implemented, the underlying algorith will be broken. Idea: drop this feature and wait explicitly with some additional tooling. + optTimeout: Option[NonNegativeDuration] = Some(timeouts.ledgerCommand), + deduplicationPeriod: Option[DeduplicationPeriod] = None, + submissionId: String = "", + minLedgerTimeAbs: Option[Instant] = None, + readAs: Seq[PartyId] = Seq.empty, + disclosedContracts: Seq[javab.data.DisclosedContract] = Seq.empty, + applicationId: String = applicationId, + ): javab.data.TransactionTreeV2 = check(FeatureFlag.Testing) { + val tx = consoleEnvironment.run { + ledgerApiCommand( + LedgerApiV2Commands.CommandService.SubmitAndWaitTransactionTree( + actAs.map(_.toLf), + readAs.map(_.toLf), + commands.map(c => Command.fromJavaProto(c.toProtoCommand)), + workflowId, + commandId, + deduplicationPeriod, + submissionId, + minLedgerTimeAbs, + disclosedContracts.map(c => DisclosedContract.fromJavaProto(c.toProto)), + domainId, + applicationId, + ) + ) + } + javab.data.TransactionTreeV2.fromProto( + TransactionTreeV2.toJavaProto(optionallyAwait(tx, tx.updateId, optTimeout)) + ) + } + + @Help.Summary( + "Submit java codegen command and wait for the resulting transaction, returning the flattened transaction or failing otherwise", + FeatureFlag.Testing, + ) + @Help.Description( + """Submits a command on behalf of the `actAs` parties, waits for the resulting transaction to commit, and returns the "flattened" transaction. + | If the timeout is set, it also waits for the transaction to appear at all other configured + | participants who were involved in the transaction. The call blocks until the transaction commits or fails; + | the timeout only specifies how long to wait at the other participants. + | Fails if the transaction doesn't commit, or if it doesn't become visible to the involved participants in + | the allotted time. + | Note that if the optTimeout is set and the involved parties are concurrently enabled/disabled or their + | participants are connected/disconnected, the command may currently result in spurious timeouts or may + | return before the transaction appears at all the involved participants.""" + ) + def submit_flat( + actAs: Seq[PartyId], + commands: Seq[javab.data.Command], + domainId: Option[DomainId] = None, + workflowId: String = "", + commandId: String = "", + // TODO(#15280) This feature wont work after V1 is removed. Also after witness blinding is implemented, the underlying algorith will be broken. Idea: drop this feature and wait explicitly with some additional tooling. + optTimeout: Option[config.NonNegativeDuration] = Some(timeouts.ledgerCommand), + deduplicationPeriod: Option[DeduplicationPeriod] = None, + submissionId: String = "", + minLedgerTimeAbs: Option[Instant] = None, + readAs: Seq[PartyId] = Seq.empty, + disclosedContracts: Seq[javab.data.DisclosedContract] = Seq.empty, + applicationId: String = applicationId, + ): javab.data.TransactionV2 = check(FeatureFlag.Testing) { + val tx = consoleEnvironment.run { + ledgerApiCommand( + LedgerApiV2Commands.CommandService.SubmitAndWaitTransaction( + actAs.map(_.toLf), + readAs.map(_.toLf), + commands.map(c => Command.fromJavaProto(c.toProtoCommand)), + workflowId, + commandId, + deduplicationPeriod, + submissionId, + minLedgerTimeAbs, + disclosedContracts.map(c => DisclosedContract.fromJavaProto(c.toProto)), + domainId, + applicationId, + ) + ) + } + javab.data.TransactionV2.fromProto( + TransactionV2.toJavaProto(optionallyAwait(tx, tx.updateId, optTimeout)) + ) + } + + @Help.Summary("Submit java codegen command asynchronously", FeatureFlag.Testing) + @Help.Description( + """Provides access to the command submission service of the Ledger API. + |See https://docs.daml.com/app-dev/services.html for documentation of the parameters.""" + ) + def submit_async( + actAs: Seq[PartyId], + commands: Seq[javab.data.Command], + domainId: Option[DomainId] = None, + workflowId: String = "", + commandId: String = "", + deduplicationPeriod: Option[DeduplicationPeriod] = None, + submissionId: String = "", + minLedgerTimeAbs: Option[Instant] = None, + readAs: Seq[PartyId] = Seq.empty, + disclosedContracts: Seq[javab.data.DisclosedContract] = Seq.empty, + applicationId: String = applicationId, + ): Unit = + ledger_api_v2.commands.submit_async( + actAs, + commands.map(c => Command.fromJavaProto(c.toProtoCommand)), + domainId, + workflowId, + commandId, + deduplicationPeriod, + submissionId, + minLedgerTimeAbs, + readAs, + disclosedContracts.map(c => DisclosedContract.fromJavaProto(c.toProto)), + applicationId, + ) + + @Help.Summary( + "Submit assign command and wait for the resulting java codegen reassignment, returning the reassignment or failing otherwise", + FeatureFlag.Testing, + ) + @Help.Description( + """Submits an unassignment command on behalf of `submitter` party, waits for the resulting unassignment to commit, and returns the reassignment. + | If waitForParticipants is set, it also waits for the reassignment(s) to appear at all other configured + | participants who were involved in the unassignment. The call blocks until the unassignment commits or fails. + | Fails if the unassignment doesn't commit, or if it doesn't become visible to the involved participants in time. + | Timout specifies the time how long to wait until the reassignment appears in the update stream for the submitting and all the specified participants.""" + ) + def submit_unassign( + submitter: PartyId, + contractId: LfContractId, + source: DomainId, + target: DomainId, + workflowId: String = "", + applicationId: String = applicationId, + submissionId: String = UUID.randomUUID().toString, + waitForParticipants: Map[ParticipantReferenceCommon, PartyId] = Map.empty, + timeout: config.NonNegativeDuration = timeouts.ledgerCommand, + ): ReassignmentV2 = + ledger_api_v2.commands + .submit_unassign( + submitter, + contractId, + source, + target, + workflowId, + applicationId, + submissionId, + waitForParticipants, + timeout, + ) + .reassignment + .pipe(Reassignment.toJavaProto) + .pipe(ReassignmentV2.fromProto) + + @Help.Summary( + "Submit assign command and wait for the resulting java codegen reassignment, returning the reassignment or failing otherwise", + FeatureFlag.Testing, + ) + @Help.Description( + """Submits a assignment command on behalf of `submitter` party, waits for the resulting assignment to commit, and returns the reassignment. + | If waitForParticipants is set, it also waits for the reassignment(s) to appear at all other configured + | participants who were involved in the assignment. The call blocks until the assignment commits or fails. + | Fails if the assignment doesn't commit, or if it doesn't become visible to the involved participants in time. + | Timout specifies the time how long to wait until the reassignment appears in the update stream for the submitting and all the specified participants. + | The unassignId should be the one returned by the corresponding submit_unassign command.""" + ) + def submit_assign( + submitter: PartyId, + unassignId: String, + source: DomainId, + target: DomainId, + workflowId: String = "", + applicationId: String = applicationId, + submissionId: String = UUID.randomUUID().toString, + waitForParticipants: Map[ParticipantReferenceCommon, PartyId] = Map.empty, + timeout: config.NonNegativeDuration = timeouts.ledgerCommand, + ): ReassignmentV2 = + ledger_api_v2.commands + .submit_assign( + submitter, + unassignId, + source, + target, + workflowId, + applicationId, + submissionId, + waitForParticipants, + timeout, + ) + .reassignment + .pipe(Reassignment.toJavaProto) + .pipe(ReassignmentV2.fromProto) + } + + @Help.Summary("Read from update stream (Java bindings)", FeatureFlag.Testing) + @Help.Group("Updates (Java bindings)") + object updates extends Helpful { + + @Help.Summary( + "Get update trees in the format expected by the Java bindings", + FeatureFlag.Testing, + ) + @Help.Description( + """This function connects to the update tree stream for the given parties and collects update trees + |until either `completeAfter` update trees have been received or `timeout` has elapsed. + |The returned update trees can be filtered to be between the given offsets (default: no filtering). + |If the participant has been pruned via `pruning.prune` and if `beginOffset` is lower than the pruning offset, + |this command fails with a `NOT_FOUND` error.""" + ) + def trees( + partyIds: Set[PartyId], + completeAfter: Int, + beginOffset: ParticipantOffset = new ParticipantOffset().withBoundary( + ParticipantOffset.ParticipantBoundary.PARTICIPANT_BEGIN + ), + endOffset: Option[ParticipantOffset] = None, + verbose: Boolean = true, + timeout: config.NonNegativeDuration = timeouts.ledgerCommand, + resultFilter: UpdateTreeWrapper => Boolean = _ => true, + ): Seq[javab.data.GetUpdateTreesResponseV2] = check(FeatureFlag.Testing)( + ledger_api_v2.updates + .trees(partyIds, completeAfter, beginOffset, endOffset, verbose, timeout, resultFilter) + .map { + case tx: TransactionTreeWrapper => + tx.transactionTree + .pipe(TransactionTreeV2.toJavaProto) + .pipe(javab.data.TransactionTreeV2.fromProto) + .pipe(new javab.data.GetUpdateTreesResponseV2(_)) + + case reassignment: ReassignmentWrapper => + reassignment.reassignment + .pipe(Reassignment.toJavaProto) + .pipe(ReassignmentV2.fromProto) + .pipe(new javab.data.GetUpdateTreesResponseV2(_)) + } + ) + + @Help.Summary( + "Get flat updates in the format expected by the Java bindings", + FeatureFlag.Testing, + ) + @Help.Description( + """This function connects to the flat update stream for the given parties and collects updates + |until either `completeAfter` flat updates have been received or `timeout` has elapsed. + |The returned updates can be filtered to be between the given offsets (default: no filtering). + |If the participant has been pruned via `pruning.prune` and if `beginOffset` is lower than the pruning offset, + |this command fails with a `NOT_FOUND` error. If you need to specify filtering conditions for template IDs and + |including create event blobs for explicit disclosure, consider using `flat_with_tx_filter`.""" + ) + def flat( + partyIds: Set[PartyId], + completeAfter: Int, + beginOffset: ParticipantOffset = new ParticipantOffset().withBoundary( + ParticipantOffset.ParticipantBoundary.PARTICIPANT_BEGIN + ), + endOffset: Option[ParticipantOffset] = None, + verbose: Boolean = true, + timeout: config.NonNegativeDuration = timeouts.ledgerCommand, + resultFilter: UpdateWrapper => Boolean = _ => true, + ): Seq[javab.data.GetUpdatesResponseV2] = check(FeatureFlag.Testing)( + ledger_api_v2.updates + .flat(partyIds, completeAfter, beginOffset, endOffset, verbose, timeout, resultFilter) + .map { + case tx: TransactionWrapper => + tx.transaction + .pipe(TransactionV2.toJavaProto) + .pipe(javab.data.TransactionV2.fromProto) + .pipe(new javab.data.GetUpdatesResponseV2(_)) + + case reassignment: ReassignmentWrapper => + reassignment.reassignment + .pipe(Reassignment.toJavaProto) + .pipe(ReassignmentV2.fromProto) + .pipe(new javab.data.GetUpdatesResponseV2(_)) + } + ) + + @Help.Summary( + "Get flat updates in the format expected by the Java bindings", + FeatureFlag.Testing, + ) + @Help.Description( + """This function connects to the flat update stream for the given transaction filter and collects updates + |until either `completeAfter` transactions have been received or `timeout` has elapsed. + |The returned transactions can be filtered to be between the given offsets (default: no filtering). + |If the participant has been pruned via `pruning.prune` and if `beginOffset` is lower than the pruning offset, + |this command fails with a `NOT_FOUND` error. If you only need to filter by a set of parties, consider using + |`flat` instead.""" + ) + def flat_with_tx_filter( + filter: javab.data.TransactionFilterV2, + completeAfter: Int, + beginOffset: ParticipantOffset = new ParticipantOffset().withBoundary( + ParticipantOffset.ParticipantBoundary.PARTICIPANT_BEGIN + ), + endOffset: Option[ParticipantOffset] = None, + verbose: Boolean = true, + timeout: config.NonNegativeDuration = timeouts.ledgerCommand, + resultFilter: UpdateWrapper => Boolean = _ => true, + ): Seq[javab.data.GetUpdatesResponseV2] = check(FeatureFlag.Testing)( + ledger_api_v2.updates + .flat_with_tx_filter( + TransactionFilterV2.fromJavaProto(filter.toProto), + completeAfter, + beginOffset, + endOffset, + verbose, + timeout, + resultFilter, + ) + .map { + case tx: TransactionWrapper => + tx.transaction + .pipe(TransactionV2.toJavaProto) + .pipe(javab.data.TransactionV2.fromProto) + .pipe(new javab.data.GetUpdatesResponseV2(_)) + + case reassignment: ReassignmentWrapper => + reassignment.reassignment + .pipe(Reassignment.toJavaProto) + .pipe(ReassignmentV2.fromProto) + .pipe(new javab.data.GetUpdatesResponseV2(_)) + } + ) + } + + @Help.Summary("Collection of Ledger API state endpoints (Java bindings)", FeatureFlag.Testing) + @Help.Group("State (Java bindings)") + object state extends Helpful { + + @Help.Summary("Read active contracts (Java bindings)", FeatureFlag.Testing) + @Help.Group("Active Contracts (Java bindings)") + object acs extends Helpful { + + @Help.Summary( + "Wait until a contract becomes available and return the Java codegen contract", + FeatureFlag.Testing, + ) + @Help.Description( + """This function can be used for contracts with a code-generated Scala model. + |You can refine your search using the `filter` function argument. + |The command will wait until the contract appears or throw an exception once it times out.""" + ) + def await[ + TC <: javab.data.codegen.Contract[TCid, T], + TCid <: javab.data.codegen.ContractId[T], + T <: javab.data.Template, + ](companion: javab.data.codegen.ContractCompanion[TC, TCid, T])( + partyId: PartyId, + predicate: TC => Boolean = (_: TC) => true, + timeout: config.NonNegativeDuration = timeouts.ledgerCommand, + ): TC = check(FeatureFlag.Testing)({ + val result = new AtomicReference[Option[TC]](None) + ConsoleMacros.utils.retry_until_true(timeout) { + val tmp = filter(companion)(partyId, predicate) + result.set(tmp.headOption) + tmp.nonEmpty + } + consoleEnvironment.runE { + result + .get() + .toRight(s"Failed to find contract of type ${companion.TEMPLATE_ID} after $timeout") + } + }) + + @Help.Summary( + "Filter the ACS for contracts of a particular Java code-generated template", + FeatureFlag.Testing, + ) + @Help.Description( + """To use this function, ensure a code-generated Java model for the target template exists. + |You can refine your search using the `predicate` function argument.""" + ) + def filter[ + TC <: javab.data.codegen.Contract[TCid, T], + TCid <: javab.data.codegen.ContractId[T], + T <: javab.data.Template, + ](templateCompanion: javab.data.codegen.ContractCompanion[TC, TCid, T])( + partyId: PartyId, + predicate: TC => Boolean = (_: TC) => true, + ): Seq[TC] = check(FeatureFlag.Testing) { + val javaTemplateId = templateCompanion.TEMPLATE_ID + val templateId = TemplateId( + javaTemplateId.getPackageId, + javaTemplateId.getModuleName, + javaTemplateId.getEntityName, + ) + ledger_api_v2.state.acs + .of_party(partyId, filterTemplates = Seq(templateId)) + .map(_.event) + .flatMap(ev => + JavaDecodeUtil + .decodeCreated(templateCompanion)( + javab.data.CreatedEvent.fromProto(CreatedEvent.toJavaProto(ev)) + ) + .toList + ) + .filter(predicate) + } + } + } + + @Help.Summary("Query event details", FeatureFlag.Testing) + @Help.Group("EventQuery") + object event_query extends Helpful { + + @Help.Summary("Get events in java codegen by contract Id", FeatureFlag.Testing) + @Help.Description("""Return events associated with the given contract Id""") + def by_contract_id( + contractId: String, + requestingParties: Seq[PartyId], + ): com.daml.ledger.api.v2.EventQueryServiceOuterClass.GetEventsByContractIdResponse = + ledger_api_v2.event_query + .by_contract_id(contractId, requestingParties) + .pipe(GetEventsByContractIdResponseV2.toJavaProto) + } + + } private def waitForUpdateId( administration: BaseLedgerApiAdministration, @@ -3236,7 +3679,7 @@ trait LedgerApiAdministration extends BaseLedgerApiAdministration { // A participant identity equality check that doesn't blow up if the participant isn't running def identityIs(pRef: ParticipantReferenceCommon, id: ParticipantId): Boolean = pRef match { - case lRef: LocalParticipantReferenceCommon => + case lRef: LocalParticipantReferenceCommon[?] => lRef.is_running && lRef.health.initialized() && lRef.id == id case rRef: RemoteParticipantReferenceCommon => rRef.health.initialized() && rRef.id == id diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/ParticipantAdministration.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/ParticipantAdministration.scala index c2f326d2f7..f8033f4ae8 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/ParticipantAdministration.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/ParticipantAdministration.scala @@ -26,6 +26,9 @@ import com.digitalasset.canton.admin.api.client.data.{ ListConnectedDomainsResult, ParticipantPruningSchedule, } +import com.digitalasset.canton.admin.participant.v0 +import com.digitalasset.canton.admin.participant.v0.PruningServiceGrpc +import com.digitalasset.canton.admin.participant.v0.PruningServiceGrpc.PruningServiceStub import com.digitalasset.canton.config.RequireTypes.PositiveInt import com.digitalasset.canton.config.{DomainTimeTrackerConfig, NonNegativeDuration} import com.digitalasset.canton.console.{ @@ -48,11 +51,9 @@ import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.health.admin.data.ParticipantStatus import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging, TracedLogger} import com.digitalasset.canton.participant.ParticipantNodeCommon +import com.digitalasset.canton.participant.admin.ResourceLimits import com.digitalasset.canton.participant.admin.grpc.TransferSearchResult import com.digitalasset.canton.participant.admin.inspection.SyncStateInspection -import com.digitalasset.canton.participant.admin.v0.PruningServiceGrpc -import com.digitalasset.canton.participant.admin.v0.PruningServiceGrpc.PruningServiceStub -import com.digitalasset.canton.participant.admin.{ResourceLimits, v0} import com.digitalasset.canton.participant.domain.DomainConnectionConfig import com.digitalasset.canton.participant.sync.TimestampedEvent import com.digitalasset.canton.protocol.messages.{ @@ -1524,7 +1525,7 @@ trait ParticipantAdministration extends FeatureFlagFilter { ParticipantAdminCommands.Transfer .TransferIn( submittingParty, - transferId.toProtoV0, + transferId.toAdminProto, targetDomain, applicationId = applicationId, submissionId = submissionId, diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/ParticipantRepairAdministration.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/ParticipantRepairAdministration.scala index c87177b625..7e81a6d3ce 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/ParticipantRepairAdministration.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/ParticipantRepairAdministration.scala @@ -8,6 +8,7 @@ import com.digitalasset.canton.admin.api.client.commands.{ GrpcAdminCommand, ParticipantAdminCommands, } +import com.digitalasset.canton.admin.participant.v0.{ExportAcsRequest, ExportAcsResponse} import com.digitalasset.canton.config.RequireTypes.PositiveInt import com.digitalasset.canton.console.CommandErrors.GenericCommandError import com.digitalasset.canton.console.{ @@ -24,7 +25,6 @@ import com.digitalasset.canton.console.{ import com.digitalasset.canton.logging.NamedLoggerFactory import com.digitalasset.canton.networking.grpc.GrpcError import com.digitalasset.canton.participant.ParticipantNodeCommon -import com.digitalasset.canton.participant.admin.v0.{ExportAcsRequest, ExportAcsResponse} import com.digitalasset.canton.participant.domain.DomainConnectionConfig import com.digitalasset.canton.protocol.{LfContractId, SerializableContractWithWitnesses} import com.digitalasset.canton.topology.{DomainId, PartyId} diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/SequencerNodeAdministration.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/SequencerNodeAdministration.scala new file mode 100644 index 0000000000..2e6d70e284 --- /dev/null +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/SequencerNodeAdministration.scala @@ -0,0 +1,157 @@ +// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.console.commands + +import cats.syntax.option.* +import com.digitalasset.canton.admin.api.client.commands.EnterpriseSequencerAdminCommands +import com.digitalasset.canton.admin.api.client.commands.EnterpriseSequencerAdminCommands.{ + BootstrapTopology, + Initialize, + InitializeX, +} +import com.digitalasset.canton.admin.api.client.data.StaticDomainParameters +import com.digitalasset.canton.console.{ + AdminCommandRunner, + FeatureFlagFilter, + Help, + Helpful, + SequencerNodeReference, +} +import com.digitalasset.canton.data.CantonTimestamp +import com.digitalasset.canton.domain.sequencing.admin.grpc.{ + InitializeSequencerResponse, + InitializeSequencerResponseX, +} +import com.digitalasset.canton.domain.sequencing.sequencer.SequencerSnapshot +import com.digitalasset.canton.topology.DomainId +import com.digitalasset.canton.topology.processing.{EffectiveTime, SequencedTime} +import com.digitalasset.canton.topology.store.StoredTopologyTransactionsX.GenericStoredTopologyTransactionsX +import com.digitalasset.canton.topology.store.{ + StoredTopologyTransactionX, + StoredTopologyTransactions, + StoredTopologyTransactionsX, +} +import com.digitalasset.canton.topology.transaction.SignedTopologyTransactionX.PositiveSignedTopologyTransactionX +import com.digitalasset.canton.topology.transaction.{ + TopologyChangeOp, + TopologyChangeOpX, + TopologyMappingX, +} + +trait SequencerNodeAdministration { + self: AdminCommandRunner with FeatureFlagFilter with SequencerNodeReference => + + private lazy val _init = new Initialization() + + def initialization: Initialization = _init + + @Help.Summary("Manage sequencer initialization") + @Help.Group("initialization") + class Initialization extends Helpful { + @Help.Summary( + "Initialize a sequencer from the beginning of the event stream. This should only be called for " + + "sequencer nodes being initialized at the same time as the corresponding domain node. " + + "This is called as part of the domain.setup.bootstrap command, so you are unlikely to need to call this directly." + ) + def initialize_from_beginning( + domainId: DomainId, + domainParameters: StaticDomainParameters, + ): InitializeSequencerResponse = + consoleEnvironment.run { + adminCommand( + Initialize(domainId, StoredTopologyTransactions.empty, domainParameters) + ) + } + + @Help.Summary( + "Dynamically initialize a sequencer from a point later than the beginning of the event stream." + + "This is called as part of the domain.setup.onboard_new_sequencer command, so you are unlikely to need to call this directly." + ) + def initialize_from_snapshot( + domainId: DomainId, + topologySnapshot: StoredTopologyTransactions[TopologyChangeOp.Positive], + sequencerSnapshot: SequencerSnapshot, + domainParameters: StaticDomainParameters, + ): InitializeSequencerResponse = + consoleEnvironment.run { + adminCommand( + Initialize(domainId, topologySnapshot, domainParameters, sequencerSnapshot.some) + ) + } + + @Help.Summary("Bootstrap topology data") + @Help.Description( + "Use this to sequence the initial batch of topology transactions which must include at least the IDM's and sequencer's" + + "key mappings. This is called as part of domain.setup.bootstrap" + ) + def bootstrap_topology( + topologySnapshot: StoredTopologyTransactions[TopologyChangeOp.Positive] + ): Unit = + consoleEnvironment.run { + adminCommand(BootstrapTopology(topologySnapshot)) + } + } +} + +trait SequencerNodeAdministrationGroupXWithInit extends SequencerAdministrationGroupX { + + @Help.Summary("Methods used for node initialization") + object setup extends ConsoleCommandGroup.Impl(this) with InitNodeId { + + @Help.Summary( + "Download sequencer snapshot at given point in time to bootstrap another sequencer" + ) + def snapshot(timestamp: CantonTimestamp): SequencerSnapshot = { + // TODO(#14074) add something like "snapshot for sequencer-id", rather than timestamp based + // we still need to keep the timestamp based such that we can provide recovery for corrupted sequencers + consoleEnvironment.run { + runner.adminCommand(EnterpriseSequencerAdminCommands.Snapshot(timestamp)) + } + } + + @Help.Summary( + "Initialize a sequencer from the beginning of the event stream. This should only be called for " + + "sequencer nodes being initialized at the same time as the corresponding domain node. " + + "This is called as part of the domain.setup.bootstrap command, so you are unlikely to need to call this directly." + ) + def assign_from_beginning( + genesisState: Seq[PositiveSignedTopologyTransactionX], + domainParameters: StaticDomainParameters, + ): InitializeSequencerResponseX = + consoleEnvironment.run { + runner.adminCommand( + InitializeX( + StoredTopologyTransactionsX[TopologyChangeOpX.Replace, TopologyMappingX]( + genesisState.map(signed => + StoredTopologyTransactionX( + SequencedTime(CantonTimestamp.MinValue.immediateSuccessor), + EffectiveTime(CantonTimestamp.MinValue.immediateSuccessor), + None, + signed, + ) + ) + ), + domainParameters.toInternal, + None, + ) + ) + } + + @Help.Summary( + "Dynamically initialize a sequencer from a point later than the beginning of the event stream." + + "This is called as part of the domain.setup.onboard_new_sequencer command, so you are unlikely to need to call this directly." + ) + def assign_from_snapshot( + topologySnapshot: GenericStoredTopologyTransactionsX, + sequencerSnapshot: SequencerSnapshot, + domainParameters: StaticDomainParameters, + ): InitializeSequencerResponseX = + consoleEnvironment.run { + runner.adminCommand( + InitializeX(topologySnapshot, domainParameters.toInternal, sequencerSnapshot.some) + ) + } + + } +} diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/TopologyAdministrationX.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/TopologyAdministrationX.scala index 8b88d209dc..0bef05c659 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/TopologyAdministrationX.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/console/commands/TopologyAdministrationX.scala @@ -14,8 +14,8 @@ import com.digitalasset.canton.admin.api.client.data.{ TrafficControlParameters, } import com.digitalasset.canton.config -import com.digitalasset.canton.config.NonNegativeDuration import com.digitalasset.canton.config.RequireTypes.{NonNegativeInt, PositiveInt, PositiveLong} +import com.digitalasset.canton.config.{NonNegativeDuration, RequireTypes} import com.digitalasset.canton.console.CommandErrors.GenericCommandError import com.digitalasset.canton.console.{ CommandErrors, @@ -692,7 +692,7 @@ class TopologyAdministrationGroupX( ), // configurable in case of a key under a decentralized namespace mustFullyAuthorize: Boolean = true, - ): Unit = propose( + ): Unit = update( key, purpose, keyOwner, @@ -734,7 +734,7 @@ class TopologyAdministrationGroupX( // configurable in case of a key under a decentralized namespace mustFullyAuthorize: Boolean = true, force: Boolean = false, - ): Unit = propose( + ): Unit = update( key, purpose, keyOwner, @@ -792,7 +792,7 @@ class TopologyAdministrationGroupX( // Authorize the new key // The owner will now have two keys, but by convention the first one added is always // used by everybody. - propose( + update( newKey.fingerprint, newKey.purpose, member, @@ -803,7 +803,7 @@ class TopologyAdministrationGroupX( ) // Remove the old key by sending the matching `Remove` transaction - propose( + update( currentKey.fingerprint, currentKey.purpose, member, @@ -819,7 +819,7 @@ class TopologyAdministrationGroupX( ) } - private def propose( + private def update( key: Fingerprint, purpose: KeyPurpose, keyOwner: Member, @@ -834,20 +834,18 @@ class TopologyAdministrationGroupX( nodeInstance: InstanceReferenceX, ): Unit = { // Ensure the specified key has a private key in the vault. - val publicKey = - nodeInstance.keys.secret - .list( - filterFingerprint = key.toProtoPrimitive, - purpose = Set(purpose), - ) match { - case privateKeyMetadata +: Nil => privateKeyMetadata.publicKey - case Nil => - throw new IllegalArgumentException("The specified key is unknown to the key owner") - case multipleKeys => - throw new IllegalArgumentException( - s"Found ${multipleKeys.size} keys where only one key was expected. Specify a full key instead of a prefix" - ) - } + val publicKey = nodeInstance.keys.secret.list( + filterFingerprint = key.toProtoPrimitive, + purpose = Set(purpose), + ) match { + case privateKeyMetadata +: Nil => privateKeyMetadata.publicKey + case Nil => + throw new IllegalArgumentException("The specified key is unknown to the key owner") + case multipleKeys => + throw new IllegalArgumentException( + s"Found ${multipleKeys.size} keys where only one key was expected. Specify a full key instead of a prefix" + ) + } // Look for an existing authorized OKM mapping. val maybePreviousState = expectAtMostOneResult( @@ -906,21 +904,42 @@ class TopologyAdministrationGroupX( } } - synchronisation - .runAdminCommand(synchronize)( - TopologyAdminCommandsX.Write - .Propose( - mapping = proposedMapping, - signedBy = signedBy.toList, - change = ops, - serial = Some(serial), - mustFullyAuthorize = mustFullyAuthorize, - forceChange = force, - store = AuthorizedStore.filterName, - ) - ) - .discard + propose( + proposedMapping, + serial, + ops, + signedBy, + AuthorizedStore.filterName, + synchronize, + mustFullyAuthorize, + force, + ).discard } + + def propose( + proposedMapping: OwnerToKeyMappingX, + serial: RequireTypes.PositiveNumeric[Int], + ops: TopologyChangeOpX = TopologyChangeOpX.Replace, + signedBy: Option[Fingerprint] = None, + store: String = AuthorizedStore.filterName, + synchronize: Option[config.NonNegativeDuration] = Some( + consoleEnvironment.commandTimeouts.bounded + ), + // configurable in case of a key under a decentralized namespace + mustFullyAuthorize: Boolean = true, + force: Boolean = false, + ): SignedTopologyTransactionX[TopologyChangeOpX, OwnerToKeyMappingX] = + synchronisation.runAdminCommand(synchronize)( + TopologyAdminCommandsX.Write.Propose( + mapping = proposedMapping, + signedBy = signedBy.toList, + store = store, + change = ops, + serial = Some(serial), + mustFullyAuthorize = mustFullyAuthorize, + forceChange = force, + ) + ) } @Help.Summary("Manage party to participant mappings") @@ -1992,7 +2011,7 @@ class TopologyAdministrationGroupX( force: must be set to true when performing a dangerous operation, such as increasing the ledgerTimeRecordTimeTolerance""" ) def propose_update( - domainId: DomainId, // TODO(#15803) check whether we can infer domainId + domainId: DomainId, update: ConsoleDynamicDomainParameters => ConsoleDynamicDomainParameters, mustFullyAuthorize: Boolean = false, // TODO(#14056) don't use the instance's root namespace key by default. diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/environment/CommunityEnvironment.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/environment/CommunityEnvironment.scala index 435735cb89..fd0e477b2a 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/environment/CommunityEnvironment.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/environment/CommunityEnvironment.scala @@ -4,6 +4,7 @@ package com.digitalasset.canton.environment import cats.syntax.either.* +import cats.syntax.option.* import com.digitalasset.canton.admin.api.client.data.CommunityCantonStatus import com.digitalasset.canton.config.{CantonCommunityConfig, TestingConfigInternal} import com.digitalasset.canton.console.{ @@ -23,7 +24,9 @@ import com.digitalasset.canton.console.{ Help, LocalDomainReference, LocalInstanceReferenceCommon, + LocalMediatorReferenceX, LocalParticipantReference, + LocalSequencerNodeReferenceX, NodeReferences, StandardConsoleOutput, } @@ -31,9 +34,14 @@ import com.digitalasset.canton.crypto.CommunityCryptoFactory import com.digitalasset.canton.crypto.admin.grpc.GrpcVaultService.CommunityGrpcVaultServiceFactory import com.digitalasset.canton.crypto.store.CryptoPrivateStore.CommunityCryptoPrivateStoreFactory import com.digitalasset.canton.domain.DomainNodeBootstrap +import com.digitalasset.canton.domain.admin.v0.EnterpriseSequencerAdministrationServiceGrpc import com.digitalasset.canton.domain.mediator.* import com.digitalasset.canton.domain.metrics.MediatorNodeMetrics +import com.digitalasset.canton.domain.sequencing.SequencerNodeBootstrapX +import com.digitalasset.canton.domain.sequencing.config.CommunitySequencerNodeXConfig +import com.digitalasset.canton.domain.sequencing.sequencer.CommunitySequencerFactory import com.digitalasset.canton.logging.NamedLoggerFactory +import com.digitalasset.canton.networking.grpc.StaticGrpcServices import com.digitalasset.canton.participant.{ParticipantNodeBootstrap, ParticipantNodeBootstrapX} import com.digitalasset.canton.resource.{ CommunityDbMigrationsFactory, @@ -77,6 +85,44 @@ class CommunityEnvironment( new CommunityHealthDumpGenerator(this, commandRunner) } + override protected def createSequencerX( + name: String, + sequencerConfig: CommunitySequencerNodeXConfig, + ): SequencerNodeBootstrapX = { + val nodeFactoryArguments = NodeFactoryArguments( + name, + sequencerConfig, + config.sequencerNodeParametersByStringX(name), + createClock(Some(SequencerNodeBootstrapX.LoggerFactoryKeyName -> name)), + metricsFactory.forSequencer(name), + testingConfig, + futureSupervisor, + loggerFactory.append(SequencerNodeBootstrapX.LoggerFactoryKeyName, name), + writeHealthDumpToFile, + configuredOpenTelemetry, + ) + + val boostrapCommonArguments = nodeFactoryArguments + .toCantonNodeBootstrapCommonArguments( + new CommunityStorageFactory(sequencerConfig.storage), + new CommunityCryptoFactory(), + new CommunityCryptoPrivateStoreFactory(), + new CommunityGrpcVaultServiceFactory, + ) + .valueOr(err => + throw new RuntimeException(s"Failed to create sequencer-x node $name: $err") + ) // TODO(i3168): Handle node startup errors gracefully + + new SequencerNodeBootstrapX( + boostrapCommonArguments, + CommunitySequencerFactory, + (_, _) => + StaticGrpcServices + .notSupportedByCommunity(EnterpriseSequencerAdministrationServiceGrpc.SERVICE, logger) + .some, + ) + } + override protected def createMediatorX( name: String, mediatorConfig: CommunityMediatorNodeXConfig, @@ -139,9 +185,11 @@ class CommunityConsoleEnvironment( override def startupOrderPrecedence(instance: LocalInstanceReferenceCommon): Int = instance match { - case _: LocalDomainReference => 1 - case _: LocalParticipantReference => 2 - case _ => 3 + case _: LocalSequencerNodeReferenceX => 1 + case _: LocalDomainReference => 2 + case _: LocalMediatorReferenceX => 3 + case _: LocalParticipantReference => 4 + case _ => 5 } override protected def createDomainReference(name: String): DomainLocalRef = diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/environment/Environment.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/environment/Environment.scala index 7709a96abc..928d28f37c 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/environment/Environment.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/environment/Environment.scala @@ -24,6 +24,7 @@ import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.domain.DomainNodeBootstrap import com.digitalasset.canton.domain.mediator.{MediatorNodeBootstrapX, MediatorNodeParameters} import com.digitalasset.canton.domain.metrics.MediatorNodeMetrics +import com.digitalasset.canton.domain.sequencing.SequencerNodeBootstrapX import com.digitalasset.canton.environment.CantonNodeBootstrap.HealthDumpFunction import com.digitalasset.canton.environment.Environment.* import com.digitalasset.canton.environment.ParticipantNodes.{ParticipantNodesOld, ParticipantNodesX} @@ -292,6 +293,16 @@ trait Environment extends NamedLogging with AutoCloseable with NoTracing { config.participantNodeParametersByString, loggerFactory, ) + + val sequencersX = new SequencerNodesX( + createSequencerX, + migrationsFactory, + timeouts, + config.sequencersByStringX, + config.sequencerNodeParametersByStringX, + loggerFactory, + ) + val mediatorsX = new MediatorNodesX( createMediatorX, @@ -305,7 +316,7 @@ trait Environment extends NamedLogging with AutoCloseable with NoTracing { // convenient grouping of all node collections for performing operations // intentionally defined in the order we'd like to start them protected def allNodes: List[Nodes[CantonNode, CantonNodeBootstrap[CantonNode]]] = - List(domains, participants, participantsX) + List(sequencersX, domains, mediatorsX, participants, participantsX) private def runningNodes: Seq[CantonNodeBootstrap[CantonNode]] = allNodes.flatMap(_.running) private def autoConnectLocalNodes(): Either[StartupError, Unit] = { @@ -527,6 +538,11 @@ trait Environment extends NamedLogging with AutoCloseable with NoTracing { .valueOr(err => throw new RuntimeException(s"Failed to create participant bootstrap: $err")) } + protected def createSequencerX( + name: String, + sequencerConfig: Config#SequencerNodeXConfigType, + ): SequencerNodeBootstrapX + protected def createMediatorX( name: String, mediatorConfig: Config#MediatorNodeXConfigType, diff --git a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/environment/Nodes.scala b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/environment/Nodes.scala index 92200b0f5f..51dbe36452 100644 --- a/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/environment/Nodes.scala +++ b/canton-3x/community/app-base/src/main/scala/com/digitalasset/canton/environment/Nodes.scala @@ -18,6 +18,11 @@ import com.digitalasset.canton.domain.mediator.{ MediatorNodeParameters, MediatorNodeX, } +import com.digitalasset.canton.domain.sequencing.config.{ + SequencerNodeConfigCommon, + SequencerNodeParameters, +} +import com.digitalasset.canton.domain.sequencing.{SequencerNodeBootstrapX, SequencerNodeX} import com.digitalasset.canton.domain.{Domain, DomainNodeBootstrap, DomainNodeParameters} import com.digitalasset.canton.lifecycle.* import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} @@ -445,6 +450,24 @@ class DomainNodes[DC <: DomainConfig]( loggerFactory, ) +class SequencerNodesX[SC <: SequencerNodeConfigCommon]( + create: (String, SC) => SequencerNodeBootstrapX, + migrationsFactory: DbMigrationsFactory, + timeouts: ProcessingTimeout, + configs: Map[String, SC], + parameters: String => SequencerNodeParameters, + loggerFactory: NamedLoggerFactory, +)(implicit ec: ExecutionContext) + extends ManagedNodes[SequencerNodeX, SC, SequencerNodeParameters, SequencerNodeBootstrapX]( + create, + migrationsFactory, + timeouts, + configs, + parameters, + startUpGroup = 0, + loggerFactory, + ) + class MediatorNodesX[MNC <: MediatorNodeConfigCommon]( create: (String, MNC) => MediatorNodeBootstrapX, migrationsFactory: DbMigrationsFactory, diff --git a/canton-3x/community/app/src/pack/config/README.md b/canton-3x/community/app/src/pack/config/README.md new file mode 100644 index 0000000000..01c43136a7 --- /dev/null +++ b/canton-3x/community/app/src/pack/config/README.md @@ -0,0 +1,17 @@ +Reference Configurations +======================== + +This directory contains a set of reference configurations. The configurations aim to provide a +starting point for your own setup. The following configurations are included: + +* `sandbox`: A simple setup for a single participant node connected to a single + domain node, using in-memory stores for testing. +* `participant`: A participant node storing data within a PostgresSQL database. +* `sequencer`: A sequencer node. +* `mediator`: A mediator node. + +If you use TLS, note that you need to have an appropriate set of TLS certificates to run the example configurations. +You can use the `tls/gen-test-certs.sh` script to generate a set of self-signed certificates for testing purposes. +The script requires openssl to be installed on your system. + +Please check the [installation guide](https://docs.daml.com/canton/usermanual/installation.html) for further details on how to run these configurations. diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/jwt/certificate.conf b/canton-3x/community/app/src/pack/config/jwt/certificate.conf similarity index 100% rename from canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/jwt/certificate.conf rename to canton-3x/community/app/src/pack/config/jwt/certificate.conf diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/jwt/jwks.conf b/canton-3x/community/app/src/pack/config/jwt/jwks.conf similarity index 100% rename from canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/jwt/jwks.conf rename to canton-3x/community/app/src/pack/config/jwt/jwks.conf diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/jwt/unsafe-hmac256.conf b/canton-3x/community/app/src/pack/config/jwt/unsafe-hmac256.conf similarity index 100% rename from canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/jwt/unsafe-hmac256.conf rename to canton-3x/community/app/src/pack/config/jwt/unsafe-hmac256.conf diff --git a/canton-3x/community/app/src/pack/config/mediator.conf b/canton-3x/community/app/src/pack/config/mediator.conf new file mode 100644 index 0000000000..fd5f02005c --- /dev/null +++ b/canton-3x/community/app/src/pack/config/mediator.conf @@ -0,0 +1,23 @@ +// Example Mediator configuration + +// Include the shared configuration file (which includes storage and monitoring) +include required("shared.conf") + +// TLS configuration +// Please check with: https://docs.daml.com/2.8.0/canton/usermanual/apis.html#tls-configuration +// Comment out the following two lines to disable TLS +include required("tls/mtls-admin-api.conf") + +canton.mediators-x.mediator { + + // Storage configuration (references included storage from shared.conf) + storage = ${_shared.storage} + storage.config.properties.databaseName = "canton_mediator" + + admin-api { + address = localhost + port = 10042 + tls = ${?_shared.admin-api-mtls} + } + +} diff --git a/canton-3x/community/app/src/pack/config/misc/debug.conf b/canton-3x/community/app/src/pack/config/misc/debug.conf new file mode 100644 index 0000000000..7f59fa62f2 --- /dev/null +++ b/canton-3x/community/app/src/pack/config/misc/debug.conf @@ -0,0 +1,8 @@ +canton.monitoring { + // Enables detailed query monitoring, which you can use to diagnose database performance issues. + log-query-cost.every = 60s + // Logs all messages that enter or exit the server. Has a significant performance impact, but can + // be very useful for debugging. + logging.api.message-payloads = true +} + diff --git a/canton-3x/community/app/src/pack/config/misc/dev-protocol.conf b/canton-3x/community/app/src/pack/config/misc/dev-protocol.conf new file mode 100644 index 0000000000..59a747e494 --- /dev/null +++ b/canton-3x/community/app/src/pack/config/misc/dev-protocol.conf @@ -0,0 +1,18 @@ +// The following configuration options turn on future features of the system. These features are not +// stable and not supported for production. You will not be able to upgrade to a stable version of Canton +// anymore. +_shared { + participant-dev-params = { + dev-version-support = true + initial-protocol-version = dev + } + // domain parameters config + domain-dev-params = { + dev-version-support = true + protocol-version = dev + } +} +canton.parameters { + non-standard-config = yes + dev-version-support = yes +} diff --git a/canton-3x/community/app/src/pack/config/misc/dev.conf b/canton-3x/community/app/src/pack/config/misc/dev.conf new file mode 100644 index 0000000000..2e804bcc65 --- /dev/null +++ b/canton-3x/community/app/src/pack/config/misc/dev.conf @@ -0,0 +1,9 @@ +// The following parameters will enable various dangerous or not yet GA supported commands. +// Please use with care, as they are not supported for production deployments or not given with +// any backwards compatibility guarantees. +canton.features { + enable-testing-commands = yes + enable-repair-commands = yes + enable-preview-commands = yes +} + diff --git a/canton-3x/community/app/src/pack/config/misc/low-latency-sequencer.conf b/canton-3x/community/app/src/pack/config/misc/low-latency-sequencer.conf new file mode 100644 index 0000000000..2411ddfd2f --- /dev/null +++ b/canton-3x/community/app/src/pack/config/misc/low-latency-sequencer.conf @@ -0,0 +1,19 @@ +// Parameter set to reduce the sequencer latency at the expensive of a higher +// database load. Please note that this change is targeting the original +// high-throughput parameter set. +// The other parameter set `low-latency` is optimised for testing such that the ledger +// response time is as low as possible at the cost of reducing the throughput. +_shared { + sequencer-writer { + // If you need lower latency, you can use these low latency parameters + payload-write-batch-max-duration = 1ms + event-write-batch-max-duration = 1ms + payload-write-max-concurrency = 10 + } + sequencer-reader { + // How often should the reader poll the database for updates + // low value = low latency, higher db load + polling-interval = 1ms + read-batch-size = 1000 + } +} diff --git a/canton-3x/community/app/src/pack/config/monitoring/prometheus.conf b/canton-3x/community/app/src/pack/config/monitoring/prometheus.conf new file mode 100644 index 0000000000..4ba1cefc1c --- /dev/null +++ b/canton-3x/community/app/src/pack/config/monitoring/prometheus.conf @@ -0,0 +1,9 @@ +canton.monitoring.metrics { + report-jvm-metrics = yes + reporters = [{ + type = prometheus + address = 0.0.0.0 + // This will expose the prometheus metrics on port 9000 + port = 9000 + }] +} diff --git a/canton-3x/community/app/src/pack/config/monitoring/tracing.conf b/canton-3x/community/app/src/pack/config/monitoring/tracing.conf new file mode 100644 index 0000000000..84731b414b --- /dev/null +++ b/canton-3x/community/app/src/pack/config/monitoring/tracing.conf @@ -0,0 +1,6 @@ +canton.monitoring.tracing.tracer.exporter = { + // zipkin or otlp are alternatives + type = jaeger + address = 169.254.0.0 + port = 14250 +} diff --git a/canton-3x/community/app/src/pack/config/participant.conf b/canton-3x/community/app/src/pack/config/participant.conf new file mode 100644 index 0000000000..c6751bcd26 --- /dev/null +++ b/canton-3x/community/app/src/pack/config/participant.conf @@ -0,0 +1,73 @@ +// Example Participant Configuration + +// Include the shared configuration file (which includes storage and monitoring) +include required("shared.conf") + +// TLS configuration +// Please check with: https://docs.daml.com/2.8.0/canton/usermanual/apis.html#tls-configuration +// Comment out the following two lines to disable TLS +include required("tls/tls-ledger-api.conf") +include required("tls/mtls-admin-api.conf") + +// JWT Configuration +// Enable JWT Authorization on the Ledger API +// Please check with: https://docs.daml.com/2.8.0/canton/usermanual/apis.html#jwt-authorization +include required("jwt/unsafe-hmac256.conf") +// include required("jwt/certificate.conf") +// include required("jwt/jwks.conf") + +canton.participants-x.participant { + + // Configure the node identifier + init.identity.node-identifier = ${?_shared.identifier} + + // Storage configuration (references included storage from shared.conf) + storage = ${_shared.storage} + storage.config.properties.databaseName = "canton_participant" + + // The following database parameter set assumes that the participants runs on a host machine with 8-16 cores + // and that the database server has 8 cores available for this node. + // https://docs.daml.com/2.8.0/canton/usermanual/persistence.html#performance + // Ideal allocation depends on your use-case. + // https://docs.daml.com/2.8.0/canton/usermanual/persistence.html#max-connection-settings + // Large: 18 = (6,6,6), Medium: 9 = (3,3,3), Small: 6 = (2,2,2) + storage.parameters { + connection-allocation { + num-ledger-api = 6 + num-reads = 6 + num-writes = 6 + } + max-connections = 18 + // Optional define the ledger-api jdbc URL directly (used for Oracle backends) + ledger-api-jdbc-url = ${?_shared.storage.ledger-api-jdbc-url} + } + + // Ledger API Configuration Section + ledger-api { + // by default, canton binds to 127.0.0.1, only enabling localhost connections + // you need to explicitly set the address to enable connections from other hosts + address = localhost + port = 10001 + tls = ${?_shared.ledger-api-tls} + // Include JWT Authorization + auth-services = ${?_shared.ledger-api.auth-services} + } + + admin-api { + address = localhost + port = 10002 + tls = ${?_shared.admin-api-mtls} + } + + // Configure GRPC Health Server for monitoring + // See https://docs.daml.com/canton/usermanual/monitoring.html#grpc-health-check-service + monitoring.grpc-health-server { + address = localhost + port = 10003 + } + + // Optionally include parameters defined in `misc/dev-protocol.conf` + // Please note that you can not use dev features in production. + parameters = ${?_shared.participant-dev-params} + +} diff --git a/canton-3x/community/app/src/pack/config/remote/mediator.conf b/canton-3x/community/app/src/pack/config/remote/mediator.conf new file mode 100644 index 0000000000..a8cfcd6e29 --- /dev/null +++ b/canton-3x/community/app/src/pack/config/remote/mediator.conf @@ -0,0 +1,14 @@ +// Example remote mediators configuration + +// Include TLS configuration +include required("../tls/mtls-admin-api.conf") + +canton { + remote-mediators-x.mediator { + admin-api { + address = localhost + port = 10042 + tls = ${?_shared.admin-api-client-mtls} + } + } +} diff --git a/canton-3x/community/app/src/pack/config/remote/participant.conf b/canton-3x/community/app/src/pack/config/remote/participant.conf new file mode 100644 index 0000000000..806e271939 --- /dev/null +++ b/canton-3x/community/app/src/pack/config/remote/participant.conf @@ -0,0 +1,19 @@ +// Example remote participant configuration + +// Include TLS configuration +include required("../tls/mtls-admin-api.conf") +include required("../tls/tls-ledger-api.conf") +canton { + remote-participants-x.participant { + ledger-api { + address = localhost + port = 10001 + tls = ${?_shared.ledger-api-client-tls} + } + admin-api { + address = localhost + port = 10002 + tls = ${?_shared.admin-api-client-mtls} + } + } +} diff --git a/canton-3x/community/app/src/pack/config/remote/sequencer.conf b/canton-3x/community/app/src/pack/config/remote/sequencer.conf new file mode 100644 index 0000000000..e2bcec78bc --- /dev/null +++ b/canton-3x/community/app/src/pack/config/remote/sequencer.conf @@ -0,0 +1,19 @@ +// Example remote sequencer configuration + +// Include TLS configuration +include required("../tls/mtls-admin-api.conf") +include required("../tls/tls-public-api.conf") +canton { + remote-sequencers-x.sequencer { + public-api = ${?_shared.public-api-client-tls} + public-api { + address = localhost + port = 10038 + } + admin-api { + address = localhost + port = 10039 + tls = ${?_shared.admin-api-client-mtls} + } + } +} diff --git a/canton-3x/community/app/src/pack/config/sandbox.conf b/canton-3x/community/app/src/pack/config/sandbox.conf new file mode 100644 index 0000000000..3ca2e205a6 --- /dev/null +++ b/canton-3x/community/app/src/pack/config/sandbox.conf @@ -0,0 +1,38 @@ +// Sandbox configuration +// +// You can start & auto-connect the sandbox with +// ./bin/canton -c config/sandbox.conf --auto-connect-local +// + +include required("misc/debug.conf") +include required("misc/dev.conf") +canton { + participants-x.sandbox { + // Enable engine stack traces for debugging + parameters.enable-engine-stack-traces = true + ledger-api { + address = localhost + port = 10021 + } + admin-api { + address = localhost + port = 10022 + } + } + sequencers-x.local { + public-api { + address = localhost + port = 10028 + } + admin-api { + address = localhost + port = 10029 + } + } + mediators-x.localMediator { + admin-api { + address = localhost + port = 10024 + } + } +} diff --git a/canton-3x/community/app/src/pack/config/sequencer.conf b/canton-3x/community/app/src/pack/config/sequencer.conf new file mode 100644 index 0000000000..d15bf758ba --- /dev/null +++ b/canton-3x/community/app/src/pack/config/sequencer.conf @@ -0,0 +1,45 @@ +// Example Sequencer Configuration + +// Include the shared configuration file (which includes storage and monitoring) +include required("shared.conf") + +// TLS configuration +// Please check with: https://docs.daml.com/2.8.0/canton/usermanual/apis.html#tls-configuration +// Comment out the following two lines to disable TLS +include required("tls/tls-public-api.conf") +include required("tls/mtls-admin-api.conf") + +// Optionally include lower latency configuration. This is necessary for pushing +// the transaction latencies from ~ 800ms to ~ 600ms at the expense of higher db +// load due to intensive polling. +// include required("misc/low-latency-sequencer.conf") + +canton.sequencers-x.sequencer { + + // Storage configuration (references included storage from shared.conf) + storage = ${_shared.storage} + storage.config.properties.databaseName = "canton_sequencer" + + public-api { + address = localhost + port = 10038 + tls = ${?_shared.public-api-tls} + } + + admin-api { + address = localhost + port = 10039 + tls = ${?_shared.admin-api-mtls} + } + + sequencer { + type = database + writer = ${?_shared.sequencer-writer} + reader = ${?_shared.sequencer-reader} + // What should the default parameterization be for the writer + // high-throughput or low-latency are two parameter sets + writer.type = high-throughput + high-availability.enabled = true + } + +} diff --git a/canton-3x/community/app/src/pack/config/shared.conf b/canton-3x/community/app/src/pack/config/shared.conf new file mode 100644 index 0000000000..f321343ac2 --- /dev/null +++ b/canton-3x/community/app/src/pack/config/shared.conf @@ -0,0 +1,28 @@ +// ------------------------------------ +// Storage Choice +// ------------------------------------ +// Include the Postgres persistence configuration mixin. +// You can define the Postgres connectivity settings either by using the environment +// variables POSTGRES_HOST, POSTGRES_PORT, POSTGRES_USER, POSTGRES_PASSWORD +// (see storage/postgres.conf for details) or setting the values directly in the config file. +// You can also remove them from the postgres.conf and add them below directly. +include required("storage/postgres.conf") + +// If you do not need persistence, you can pick +// include required("storage/memory.conf") + +// Monitoring Configuration +// Turn on Prometheus metrics +include required("monitoring/prometheus.conf") +// Turn on tracing with Jaeger, Zipkin or OTLP +// include require ("monitoring/tracing.conf") + +// Upon automatic initialisation, pick the following prefix for the node identifier +// the node will then be :: +// Random is good for larger networks when you don not want that others know who you +// are. Explicit is better for troubleshooting. +_shared.identifier = { + type = random + // type = explicit + // name = "myNodeIdentifier" +} diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/storage/h2.conf b/canton-3x/community/app/src/pack/config/storage/h2.conf similarity index 74% rename from canton-3x/community/app/src/pack/examples/03-advanced-configuration/storage/h2.conf rename to canton-3x/community/app/src/pack/config/storage/h2.conf index 94529a52a8..846ba62958 100644 --- a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/storage/h2.conf +++ b/canton-3x/community/app/src/pack/config/storage/h2.conf @@ -3,11 +3,8 @@ # This file defines a shared configuration resources. You can mix it into your configuration by # refer to the shared storage resource and add the database name. # -# Check nodes/participant1.conf as an example +# Please note that using H2 is unstable and not supported other than for testing. # -# Please note that using H2 is currently not advised and not supported. -# - _shared { storage { type = "h2" @@ -17,4 +14,4 @@ _shared { driver = org.h2.Driver } } -} \ No newline at end of file +} diff --git a/canton-3x/community/app/src/pack/config/storage/memory.conf b/canton-3x/community/app/src/pack/config/storage/memory.conf new file mode 100644 index 0000000000..d6d1505da7 --- /dev/null +++ b/canton-3x/community/app/src/pack/config/storage/memory.conf @@ -0,0 +1,5 @@ +_shared { + storage { + type = "memory" + } +} diff --git a/canton-3x/community/app/src/pack/config/storage/postgres.conf b/canton-3x/community/app/src/pack/config/storage/postgres.conf new file mode 100644 index 0000000000..5b0cfcbe21 --- /dev/null +++ b/canton-3x/community/app/src/pack/config/storage/postgres.conf @@ -0,0 +1,47 @@ +# Postgres persistence configuration mixin +# +# This file defines a shared configuration resources. You can mix it into your configuration by +# refer to the shared storage resource and add the database name. +# +# Example: +# participant1 { +# storage = ${_shared.storage} +# storage.config.properties.databaseName = "participant1" +# } +# +# The user and password is not set. You want to either change this configuration file or pass +# the settings in via environment variables POSTGRES_USER and POSTGRES_PASSWORD. +# +_shared { + storage { + type = postgres + config { + dataSourceClass = "org.postgresql.ds.PGSimpleDataSource" + properties = { + serverName = "localhost" + # the next line will override above "serverName" in case the environment variable POSTGRES_HOST exists + # which makes it optional + serverName = ${?POSTGRES_HOST} + portNumber = "5432" + portNumber = ${?POSTGRES_PORT} + # user and password are equired + user = ${POSTGRES_USER} + password = ${POSTGRES_PASSWORD} + } + } + parameters { + # If defined, will configure the number of database connections per node. + # Please note that the number of connections can be fine tuned for participant nodes (see participant.conf) + max-connections = ${?POSTGRES_NUM_CONNECTIONS} + # If true, then database migrations will be applied on startup automatically + # Otherwise, you will have to run the migration manually using participant.db.migrate() + migrate-and-start = false + # If true (default), then the node will fail to start if it can not connect to the database. + # The setting is useful during initial deployment to get immediate feedback when the + # database is not available. + # In a production setup, you might want to set this to false to allow uncoordinated startups between + # the database and the node. + fail-fast-on-startup = true + } + } +} diff --git a/canton-3x/community/app/src/pack/config/tls/certs-common.sh b/canton-3x/community/app/src/pack/config/tls/certs-common.sh new file mode 100644 index 0000000000..143b1a5d70 --- /dev/null +++ b/canton-3x/community/app/src/pack/config/tls/certs-common.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +# Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# architecture-handbook-entry-begin: GenTestCertsCmds +DAYS=3650 + +function create_key { + local name=$1 + openssl genrsa -out "${name}.key" 4096 + # netty requires the keys in pkcs8 format, therefore convert them appropriately + openssl pkcs8 -topk8 -nocrypt -in "${name}.key" -out "${name}.pem" +} + +# create self signed certificate +function create_certificate { + local name=$1 + local subj=$2 + openssl req -new -x509 -sha256 -key "${name}.key" \ + -out "${name}.crt" -days ${DAYS} -subj "$subj" +} + +# create certificate signing request with subject and SAN +# we need the SANs as our certificates also need to include localhost or the +# loopback IP for the console access to the admin-api and the ledger-api +function create_csr { + local name=$1 + local subj=$2 + local san=$3 + ( + echo "authorityKeyIdentifier=keyid,issuer" + echo "basicConstraints=CA:FALSE" + echo "keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment" + ) > ${name}.ext + if [[ -n $san ]]; then + echo "subjectAltName=${san}" >> ${name}.ext + fi + # create certificate (but ensure that localhost is there as SAN as otherwise, admin local connections won't work) + openssl req -new -sha256 -key "${name}.key" -out "${name}.csr" -subj "$subj" +} + +function sign_csr { + local name=$1 + local sign=$2 + openssl x509 -req -sha256 -in "${name}.csr" -extfile "${name}.ext" -CA "${sign}.crt" -CAkey "${sign}.key" -CAcreateserial \ + -out "${name}.crt" -days ${DAYS} + rm "${name}.ext" "${name}.csr" +} + +function print_certificate { + local name=$1 + openssl x509 -in "${name}.crt" -text -noout +} + +# architecture-handbook-entry-end: GenTestCertsCmds diff --git a/canton-3x/community/app/src/pack/config/tls/gen-test-certs.sh b/canton-3x/community/app/src/pack/config/tls/gen-test-certs.sh new file mode 100755 index 0000000000..f66cfad161 --- /dev/null +++ b/canton-3x/community/app/src/pack/config/tls/gen-test-certs.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# architecture-handbook-entry-begin: GenTestCerts + +# include certs-common.sh from config/tls +. "$(dirname "${BASH_SOURCE[0]}")/certs-common.sh" + +# create root certificate such that we can issue self-signed certs +create_key "root-ca" +create_certificate "root-ca" "/O=TESTING/OU=ROOT CA/emailAddress=canton@digitalasset.com" +print_certificate "root-ca" + +# create public api certificate +create_key "public-api" +create_csr "public-api" "/O=TESTING/OU=DOMAIN/CN=localhost/emailAddress=canton@digitalasset.com" "DNS:localhost,IP:127.0.0.1" +sign_csr "public-api" "root-ca" +print_certificate "public-api" + +# create participant ledger-api certificate +create_key "ledger-api" +create_csr "ledger-api" "/O=TESTING/OU=PARTICIPANT/CN=localhost/emailAddress=canton@digitalasset.com" "DNS:localhost,IP:127.0.0.1" +sign_csr "ledger-api" "root-ca" + +# create participant admin-api certificate +create_key "admin-api" +create_csr "admin-api" "/O=TESTING/OU=PARTICIPANT ADMIN/CN=localhost/emailAddress=canton@digitalasset.com" "DNS:localhost,IP:127.0.0.1" +sign_csr "admin-api" "root-ca" + +# create participant client key and certificate +create_key "admin-client" +create_csr "admin-client" "/O=TESTING/OU=PARTICIPANT ADMIN CLIENT/CN=localhost/emailAddress=canton@digitalasset.com" +sign_csr "admin-client" "root-ca" +print_certificate "admin-client" +# architecture-handbook-entry-end: GenTestCerts + diff --git a/canton-3x/community/app/src/pack/config/tls/mtls-admin-api.conf b/canton-3x/community/app/src/pack/config/tls/mtls-admin-api.conf new file mode 100644 index 0000000000..48cca751af --- /dev/null +++ b/canton-3x/community/app/src/pack/config/tls/mtls-admin-api.conf @@ -0,0 +1,38 @@ +include required("tls-cert-location.conf") +_shared { + admin-api-mtls { + // Certificate and Key used by Admin API server + cert-chain-file = ${?_TLS_CERT_LOCATION}"/admin-api.crt" + private-key-file = ${?_TLS_CERT_LOCATION}"/admin-api.pem" + + // Certificate used to validate client certificates. The file also needs to be provided + // if we use a self-signed certificate, such that the internal processes can connect to + // the APIs. + trust-collection-file = ${?_TLS_CERT_LOCATION}"/root-ca.crt" + client-auth = { + // none, optional and require are supported + type = require + // If clients are required to authenticate as well, we need to provide a client + // certificate and the key, as Canton has internal processes that need to connect to these + // APIs. If the server certificate is trusted by the trust-collection, then you can + // just use the server certificates (which usually happens if you use self-signed certs as we + // do in this example). Otherwise, you need to create separate ones. + admin-client { + // In this example, we use the same certificate as the server certificate. + // Please the the remote participant config to see how to configure a remote client. + cert-chain-file = ${?_TLS_CERT_LOCATION}"/admin-api.crt" + private-key-file = ${?_TLS_CERT_LOCATION}"/admin-api.pem" + } + } + } + admin-api-client-mtls { + // Certificate and Key used by remote client + client-cert { + cert-chain-file = ${?_TLS_CERT_LOCATION}"/admin-api.crt" + private-key-file = ${?_TLS_CERT_LOCATION}"/admin-api.pem" + } + // The trust collection used to verify the server certificate. Used here because of the self-signed certs. + trust-collection-file = ${?_TLS_CERT_LOCATION}"/root-ca.crt" + } +} + diff --git a/canton-3x/community/app/src/pack/config/tls/tls-cert-location.conf b/canton-3x/community/app/src/pack/config/tls/tls-cert-location.conf new file mode 100644 index 0000000000..ecc3c234a3 --- /dev/null +++ b/canton-3x/community/app/src/pack/config/tls/tls-cert-location.conf @@ -0,0 +1,2 @@ +_TLS_CERT_LOCATION="config/tls" +_TLS_CERT_LOCATION=${?TLS_CERT_LOCATION} diff --git a/canton-3x/community/app/src/pack/config/tls/tls-ledger-api.conf b/canton-3x/community/app/src/pack/config/tls/tls-ledger-api.conf new file mode 100644 index 0000000000..097b556f15 --- /dev/null +++ b/canton-3x/community/app/src/pack/config/tls/tls-ledger-api.conf @@ -0,0 +1,19 @@ +include required("tls-cert-location.conf") +_shared { + ledger-api-tls { + // Certificate to be used by the server + cert-chain-file = ${?_TLS_CERT_LOCATION}"/ledger-api.crt" + // The private key of the server + private-key-file = ${?_TLS_CERT_LOCATION}"/ledger-api.pem" + // The trust collection. we use it in this example as our certificates are self + // signed but we need it such that the internal canton processes can connect to the + // Ledger API. In a production environment, you would use a proper CA and therefore + // not require this. + trust-collection-file = ${?_TLS_CERT_LOCATION}"/root-ca.crt" + } + + ledger-api-client-tls { + // The trust collection used to verify the server certificate. Used here because of the self-signed certs. + trust-collection-file = ${?_TLS_CERT_LOCATION}"/root-ca.crt" + } +} diff --git a/canton-3x/community/app/src/pack/config/tls/tls-public-api.conf b/canton-3x/community/app/src/pack/config/tls/tls-public-api.conf new file mode 100644 index 0000000000..1fff22e33a --- /dev/null +++ b/canton-3x/community/app/src/pack/config/tls/tls-public-api.conf @@ -0,0 +1,14 @@ +include required("tls-cert-location.conf") +_shared { + public-api-tls { + // certificate to be used by the server + cert-chain-file = ${?_TLS_CERT_LOCATION}"/public-api.crt" + // the private key of the server + private-key-file = ${?_TLS_CERT_LOCATION}"/public-api.pem" + } + public-api-client-tls { + transport-security = true + // The trust collection used to verify the server certificate. Used here because of the self-signed certs. + custom-trust-certificates.pem-file = ${?_TLS_CERT_LOCATION}"/root-ca.crt" + } +} diff --git a/canton-3x/community/app/src/pack/config/utils/postgres/databases b/canton-3x/community/app/src/pack/config/utils/postgres/databases new file mode 100644 index 0000000000..33d52a41ca --- /dev/null +++ b/canton-3x/community/app/src/pack/config/utils/postgres/databases @@ -0,0 +1,3 @@ +canton_participant +canton_mediator +canton_sequencer diff --git a/canton-3x/community/app/src/pack/config/utils/postgres/db.env b/canton-3x/community/app/src/pack/config/utils/postgres/db.env new file mode 100644 index 0000000000..3c10f505bb --- /dev/null +++ b/canton-3x/community/app/src/pack/config/utils/postgres/db.env @@ -0,0 +1,10 @@ +#!/bin/bash + +// user-manual-entry-begin: PostgresDbEnvConfiguration +export POSTGRES_HOST="localhost" +export POSTGRES_USER="test-user" +export POSTGRES_PASSWORD="test-password" +export POSTGRES_DB=postgres +export POSTGRES_PORT=5432 +// user-manual-entry-end: PostgresDbEnvConfiguration +export DBPREFIX="" diff --git a/canton-3x/community/app/src/pack/config/utils/postgres/db.sh b/canton-3x/community/app/src/pack/config/utils/postgres/db.sh new file mode 100755 index 0000000000..07a6b2a041 --- /dev/null +++ b/canton-3x/community/app/src/pack/config/utils/postgres/db.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash + +# Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +function check_file() { + local -r file="$1" + if [[ ! -e $file ]]; then + echo "Please run this script from the directory containing the $file file." + exit 1 + fi +} + +function do_usage() { + echo "Usage: $0 " + echo " setup: create databases" + echo " reset: drop and recreate databases" + echo " drop: drop databases" + echo " create-user: create user" + echo " start [durable]: start docker db. Without durable, it will remove the container after exit" + echo " resume: resume durable docker db" + echo " stop: stop docker db" +} + +function do_setup() { + for db in $(cat "databases") + do + echo "creating db ${db}" + echo "create database ${DBPREFIX}${db}; grant all on database ${DBPREFIX}${db} to current_user;" | \ + PGPASSWORD=$POSTGRES_PASSWORD psql -h $POSTGRES_HOST -p $POSTGRES_PORT $POSTGRES_DB $POSTGRES_USER + done +} + +function do_drop() { + for db in $(cat "databases") + do + echo "dropping db ${db}" + echo "drop database if exists ${DBPREFIX}${db};" | \ + PGPASSWORD=$POSTGRES_PASSWORD psql -h $POSTGRES_HOST -p $POSTGRES_PORT $POSTGRES_DB $POSTGRES_USER + done +} + +function do_create_user() { + echo "Creating user ${POSTGRES_USER} (assumes your default user can do that on ${POSTGRES_DB})..." + echo "CREATE ROLE \"${POSTGRES_USER}\" LOGIN PASSWORD '${POSTGRES_PASSWORD}';ALTER USER \"${POSTGRES_USER}\" createdb;" | \ + psql -h $POSTGRES_HOST -p $POSTGRES_PORT ${POSTGRES_DB} +} + +function do_start_docker_db() { + if [ "$1" == "durable" ]; then + removeDockerAfterExit="" + echo "starting durable docker based postgres" + else + echo "starting non-durable docker based postgres" + removeDockerAfterExit="--rm" + fi + docker run -d ${removeDockerAfterExit} --name canton-postgres \ + --shm-size 1024mb \ + --publish ${POSTGRES_PORT}:5432 \ + -e POSTGRES_USER=$POSTGRES_USER \ + -e POSTGRES_PASSWORD=$POSTGRES_PASSWORD \ + -e POSTGRES_DB=$POSTGRES_DB \ + -v "$PWD/postgres.conf":/etc/postgresql/postgresql.conf \ + postgres:12 \ + -c 'config_file=/etc/postgresql/postgresql.conf' +} + +function do_resume_docker_db() { + echo "resuming docker based postgres" + docker start canton-postgres +} + +function do_stop_docker_db() { + echo "stopping docker based postgres" + docker stop canton-postgres +} + +function check_env { + if [[ -z "$POSTGRES_USER" || -z "$POSTGRES_HOST" || -z "$POSTGRES_DB" || -z "$POSTGRES_PASSWORD" || -z "$POSTGRES_PORT" ]]; then + echo 1 + else + echo 0 + fi +} + +check_file "databases" +if [[ $(check_env) -ne 0 ]]; then + echo "Looking for db.env as environment variables are not set: POSTGRES_USER, POSTGRES_HOST, POSTGRES_DB, POSTGRES_PASSWORD, POSTGRES_PORT." + echo $(env | grep -v POSTGRES_PASSWORD | grep POSTGRES) + check_file "db.env" + source "db.env" + echo $(env | grep -v POSTGRES_PASSWORD | grep POSTGRES) + if [[ $(check_env) -ne 0 ]]; then + echo "POSTGRES_ environment is not properly set in db.env" + exit 1 + fi +else + echo "Using host=${POSTGRES_HOST}, port=${POSTGRES_PORT} user=${POSTGRES_USER}, db=${POSTGRES_DB} from environment" +fi + + +case "$1" in + setup) + do_setup + ;; + reset) + do_drop + do_setup + ;; + drop) + do_drop + ;; + create-user) + do_create_user + ;; + start) + do_start_docker_db $2 + ;; + resume) + do_resume_docker_db + ;; + stop) + do_stop_docker_db + ;; + *) + do_usage + ;; +esac diff --git a/canton-3x/community/app/src/pack/config/utils/postgres/postgres.conf b/canton-3x/community/app/src/pack/config/utils/postgres/postgres.conf new file mode 100644 index 0000000000..c347fc9237 --- /dev/null +++ b/canton-3x/community/app/src/pack/config/utils/postgres/postgres.conf @@ -0,0 +1,37 @@ +# Note, this config has been created using https://pgtune.leopard.in.ua/ +# It targets a standard small docker deployment. +# DB Version: 12 +# OS Type: linux +# DB Type: oltp +# Total Memory (RAM): 8 GB +# CPUs num: 4 +# Connections num: 250 +# Data Storage: ssd + +listen_addresses = '*' +log_destination = 'stderr' +logging_collector = on +log_directory = '/var/log/postgresql/' +log_file_mode = 0644 +log_filename = 'postgresql-%Y-%m-%d-%H.log' +log_min_messages = info +log_min_duration_statement = 2500 + +max_connections = 250 +shared_buffers = 4GB +effective_cache_size = 6GB +maintenance_work_mem = 512MB +checkpoint_completion_target = 0.9 +wal_buffers = 16MB +default_statistics_target = 100 +random_page_cost = 1.1 +effective_io_concurrency = 200 +work_mem = 4194kB +huge_pages = off +min_wal_size = 2GB +max_wal_size = 8GB +max_worker_processes = 4 +max_parallel_workers_per_gather = 2 +max_parallel_workers = 4 +max_parallel_maintenance_workers = 2 + diff --git a/canton-3x/community/app/src/pack/examples/02-global-domain/README.md b/canton-3x/community/app/src/pack/examples/02-global-domain/README.md deleted file mode 100644 index 37aa31ba60..0000000000 --- a/canton-3x/community/app/src/pack/examples/02-global-domain/README.md +++ /dev/null @@ -1,33 +0,0 @@ -# Connection to Canton.Global - -*** -WARNING: The global Canton domain is currently not running. This example does not work at the moment. -*** -TODO(#7564) Make this example work again once the global domain is up -*** - - -Participants require a domain to communicate with each other. Digital Asset is running a generally available -global Canton domain (Canton.Global). Any participant can decide to connect to the global domain and use it -for bilateral communication. - -The global domain connectivity example demonstrates how to connect a participant node -to the global Canton domain. Currently, the global domain is operated as a test-net. -Longer term, the global domain will serve as a global fall-back committer which can be -used if no closer committer is available. - -The global domain connectivity example contains two files, a configuration file and a -script which invokes the necessary registration call and subsequently tests the connection -by pinging the digital asset node. - -``` - ../../bin/canton -c global-domain-participant.conf --bootstrap global-domain-participant.canton -``` - -After invoking above script, you will be prompted the terms of service for using the global -domain. You will have to accept it once in order to be able to use it. - -Please note that right now, the global domain is a pure test-net and we are regularly resetting -the domain entirely, wiping all the content, as we are still developing the protocol. Therefore, -just use it for demonstration purposes. - diff --git a/canton-3x/community/app/src/pack/examples/02-global-domain/global-domain-participant.canton b/canton-3x/community/app/src/pack/examples/02-global-domain/global-domain-participant.canton deleted file mode 100644 index 7bb5c1fa32..0000000000 --- a/canton-3x/community/app/src/pack/examples/02-global-domain/global-domain-participant.canton +++ /dev/null @@ -1,13 +0,0 @@ -nodes.local.start() - -val domainUrl = sys.env.get("DOMAIN_URL").getOrElse("https://canton.global") - -val myself = participant1 - -myself.domains.connect("global", domainUrl) - -myself.health.ping(myself) - -val da = myself.parties.list(filterParty="digitalasset").head.participants.head.participant - -myself.health.ping(da) diff --git a/canton-3x/community/app/src/pack/examples/02-global-domain/global-domain-participant.conf b/canton-3x/community/app/src/pack/examples/02-global-domain/global-domain-participant.conf deleted file mode 100644 index e9afd10fef..0000000000 --- a/canton-3x/community/app/src/pack/examples/02-global-domain/global-domain-participant.conf +++ /dev/null @@ -1,16 +0,0 @@ -canton { - participants { - participant1 { - admin-api { - port= 6012 - } - ledger-api { - port = 6011 - } - storage { - type = memory - } - parameters.admin-workflow.bong-test-max-level = 12 - } - } -} diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/README.md b/canton-3x/community/app/src/pack/examples/03-advanced-configuration/README.md index 7efe9b1e35..5740485327 100644 --- a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/README.md +++ b/canton-3x/community/app/src/pack/examples/03-advanced-configuration/README.md @@ -1,137 +1,3 @@ # Advanced Configuration Example -This example directory contains a collection of configuration files that can be used to setup domains or -participants for various purposes. The directory contains a set of sub-folders: - - - storage: contains "storage mixins" such as [memory.conf](storage/memory.conf) or [postgres.conf](storage/postgres.conf) - - nodes: contains a set of node defintions for domains and participants - - api: contains "api mixins" that modify the API behaviour such as binding to a public address or including jwt authorization - - remote: contains a set of remote node definitions for the nodes in the nodes directory. - - parameters: contains "parameter mixins" that modify the node behaviour in various ways. - -## Persistence - -For every setup, you need to decide which persistence layer you want to use. Supported are [memory.conf](storage/memory.conf), -[postgres.conf](storage/postgres.conf) or Oracle (Enterprise). Please [consult the manual](https://docs.daml.com/canton/usermanual/installation.html#persistence-using-postgres) -for further instructions. The examples here will illustrate the usage using the in-memory configuration. - -There is a small helper script in [dbinit.py](storage/dbinit.py) which you can use to create the appropriate SQL commands -to create users and databases for a series of nodes. This is convenient if you are setting up a test-network. You can -run it using: - -``` - python3 examples/03-advanced-configuration/storage/dbinit.py \ - --type=postgres --user=canton --pwd= --participants=2 --domains=1 --drop -``` - -Please run the script with ``--help`` to get an overview of all commands. Generally, you would just pipe the output -to your SQL console. - -## Nodes - -The nodes directory contains a set of base configuration files that can be used together with the mix-ins. - -### Domain - -Start a domain with the following command: - -``` - ./bin/canton -c examples/03-advanced-configuration/storage/memory.conf,examples/03-advanced-configuration/nodes/domain1.conf -``` - -The domain can be started without any bootstrap script, as it self-initialises by default, waiting for incoming connections. - -If you pass in multiple configuration files, they will be combined. It doesn't matter if you separate the -configurations using `,` or if you pass them with several `-c` options. - -NOTE: If you unpacked the zip directory, then you might have to make the canton startup script executable - (`chmod u+x bin/canton`). - -### Participants - -The participant(s) can be started the same way, just by pointing to the participant configuration file. -However, before we can use the participant for any Daml processing, we need to connect it to a domain. You can -connect to the domain interactively, or use the [initialisation script](participant-init.canton). - -``` - ./bin/canton -c examples/03-advanced-configuration/storage/memory.conf \ - -c examples/03-advanced-configuration/nodes/participant1.conf,examples/03-advanced-configuration/nodes/participant2.conf \ - --bootstrap=examples/03-advanced-configuration/participant-init.canton -``` - -The initialisation script assumes that the domain can be reached via `localhost`, which needs to change if the domain -runs on a different server. - -A setup with more participant nodes can be created using the [participant](nodes/participant1.conf) as a template. -The same applies to the domain configuration. The instance names should be changed (`participant1` to something else), -as otherwise, distinguishing the nodes in a trial run will be difficult. - -## API - -By default, all the APIs only bind to localhost. If you want to expose them on the network, you should secure them using -TLS and JWT. You can use the mixins configuration in the ``api`` subdirectory for your convenience. - -## Parameters - -The parameters directory contains a set of mix-ins to modify the behaviour of your nodes. - -- [nonuck.conf](nodes/nonuck.conf) enable non-UCK mode such that you can use multiple domains per participant node (preview). - -## Test Your Setup - -Assuming that you have started both participants and a domain, you can verify that the system works by having -participant2 pinging participant1 (the other way around also works). A ping here is just a built-in Daml -contract which gets sent from one participant to another, and the other responds by exercising a choice. - -First, just make sure that the `participant2` is connected to the domain by testing whether the following command -returns `true` -``` -@ participant2.domains.active("mydomain") -``` - -In order to ping participant1, participant2 must know participant1's `ParticipantId`. You could obtain this from -participant1's instance of the Canton console using the command `participant1.id` and copy-pasting the resulting -`ParticipantId` to participant2's Canton console. Another option is to lookup participant1's ID directly using -participant2's console: -``` -@ val participant1Id = participant2.parties.list(filterParticipant="participant1").head.participants.head.participant -``` -Using the console for participant2, you can now get the two participants to ping each other: -``` -@ participant2.health.ping(participant1Id) -``` - -## Running as Background Process - -If you start Canton with the commands above, you will always be in interactive mode within the Canton console. -You can start Canton as well as a non-interactive process using -``` - ./bin/canton daemon -c examples/03-advanced-configuration/storage/memory.conf \ - -c examples/03-advanced-configuration/nodes/participant1.conf \ - --bootstrap examples/03-advanced-configuration/participant-init.canton -``` - -## Connect To Remote Nodes - -In many cases, the nodes will run in a background process, started as `daemon`, while the user would -still like the convenience of using the console. This can be achieved by defining remote domains and -participants in the configuration file. - -A participant or domain configuration can be turned into a remote config using - -``` - ./bin/canton generate remote-config -c examples/03-advanced-configuration/storage/memory.conf,examples/03-advanced-configuration/nodes/participant1.conf -``` - -Then, if you start Canton using -``` - ./bin/canton -c remote-participant1.conf -``` -you will have a new instance `participant1`, which will expose most but not all commands -that a node exposes. As an example, run: -``` - participant1.health.status -``` - -Please note that depending on your setup, you might have to adjust the target ip address. - +Please note that the configuration examples have been replaced by the reference configuration in the config directory. diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/jwt/leeway-parameters.conf b/canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/jwt/leeway-parameters.conf deleted file mode 100644 index 796667aad3..0000000000 --- a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/jwt/leeway-parameters.conf +++ /dev/null @@ -1,8 +0,0 @@ -_shared { - parameters.ledger-api-server-parameters.jwt-timestamp-leeway { - default = 5 - expires-at = 10 - issued-at = 15 - not-before = 20 - } -} diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/large-in-memory-fan-out.conf b/canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/large-in-memory-fan-out.conf deleted file mode 100644 index df27c97e2a..0000000000 --- a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/large-in-memory-fan-out.conf +++ /dev/null @@ -1,7 +0,0 @@ -_shared { - ledger-api { - index-service { - max-transactions-in-memory-fan-out-buffer-size = 10000 // default 1000 - } - } -} diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/large-ledger-api-cache.conf b/canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/large-ledger-api-cache.conf deleted file mode 100644 index 9c94f7b20e..0000000000 --- a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/large-ledger-api-cache.conf +++ /dev/null @@ -1,8 +0,0 @@ -_shared { - ledger-api { - index-service { - max-contract-state-cache-size = 100000 // default 1e4 - max-contract-key-state-cache-size = 100000 // default 1e4 - } - } -} diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/public-admin.conf b/canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/public-admin.conf deleted file mode 100644 index b8c808ecac..0000000000 --- a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/public-admin.conf +++ /dev/null @@ -1,7 +0,0 @@ -_shared { - admin-api { - // by default, canton binds to 127.0.0.1, only enabling localhost connections - // you need to explicitly set the address to enable connections from other hosts - address = 0.0.0.0 - } -} \ No newline at end of file diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/public.conf b/canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/public.conf deleted file mode 100644 index af07b1c907..0000000000 --- a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/public.conf +++ /dev/null @@ -1,11 +0,0 @@ -_shared { - public-api { - // by default, canton binds to 127.0.0.1, only enabling localhost connections - // you need to explicitly set the address to enable connections from other hosts - address = 0.0.0.0 - } - ledger-api { - // same as for public-api - address = 0.0.0.0 - } -} \ No newline at end of file diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/wildcard.conf b/canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/wildcard.conf deleted file mode 100644 index 0caf7d3a94..0000000000 --- a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/api/wildcard.conf +++ /dev/null @@ -1,7 +0,0 @@ -_shared { - ledger-api { - auth-services = [{ - type = wildcard - }] - } -} diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/nodes/domain1.conf b/canton-3x/community/app/src/pack/examples/03-advanced-configuration/nodes/domain1.conf deleted file mode 100644 index 1a1d517ca1..0000000000 --- a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/nodes/domain1.conf +++ /dev/null @@ -1,18 +0,0 @@ -canton { - domains { - domain1 { - storage = ${_shared.storage} - storage.config.properties.databaseName = "domain1" - init.domain-parameters.unique-contract-keys = ${?_.shared.unique-contract-keys} - public-api { - port = 10018 - // if defined, this include will override the address we bind to. default is 127.0.0.1 - address = ${?_shared.public-api.address} - } - admin-api { - port = 10019 - address = ${?_shared.admin-api.address} - } - } - } -} \ No newline at end of file diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/nodes/participant1.conf b/canton-3x/community/app/src/pack/examples/03-advanced-configuration/nodes/participant1.conf deleted file mode 100644 index cccf64c906..0000000000 --- a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/nodes/participant1.conf +++ /dev/null @@ -1,19 +0,0 @@ -canton { - participants { - participant1 { - storage = ${_shared.storage} - storage.config.properties.databaseName = "participant1" - init.parameters.unique-contract-keys = ${?_.shared.unique-contract-keys} - admin-api { - port = 10012 - // if defined, this include will override the address we bind to. default is 127.0.0.1 - address = ${?_shared.admin-api.address} - } - ledger-api { - port = 10011 - address = ${?_shared.ledger-api.address} - auth-services = ${?_shared.ledger-api.auth-services} - } - } - } -} \ No newline at end of file diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/nodes/participant2.conf b/canton-3x/community/app/src/pack/examples/03-advanced-configuration/nodes/participant2.conf deleted file mode 100644 index 6ea6e40d4a..0000000000 --- a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/nodes/participant2.conf +++ /dev/null @@ -1,19 +0,0 @@ -canton { - participants { - participant2 { - storage = ${_shared.storage} - storage.config.properties.databaseName = "participant2" - init.parameters.unique-contract-keys = ${?_.shared.unique-contract-keys} - admin-api { - port = 10022 - // if defined, this include will override the address we bind to. default is 127.0.0.1 - address = ${?_shared.admin-api.address} - } - ledger-api { - port = 10021 - address = ${?_shared.ledger-api.address} - auth-services = ${?_shared.ledger-api.auth-services} - } - } - } -} \ No newline at end of file diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/nodes/participant3.conf b/canton-3x/community/app/src/pack/examples/03-advanced-configuration/nodes/participant3.conf deleted file mode 100644 index fd8c0cf04b..0000000000 --- a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/nodes/participant3.conf +++ /dev/null @@ -1,19 +0,0 @@ -canton { - participants { - participant3 { - storage = ${_shared.storage} - storage.config.properties.databaseName = "participant3" - init.parameters.unique-contract-keys = ${?_.shared.unique-contract-keys} - admin-api { - port = 10032 - // if defined, this include will override the address we bind to. default is 127.0.0.1 - address = ${?_shared.admin-api.address} - } - ledger-api { - port = 10031 - address = ${?_shared.ledger-api.address} - auth-services = ${?_shared.ledger-api.auth-services} - } - } - } -} diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/nodes/participant4.conf b/canton-3x/community/app/src/pack/examples/03-advanced-configuration/nodes/participant4.conf deleted file mode 100644 index 1d3d34a617..0000000000 --- a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/nodes/participant4.conf +++ /dev/null @@ -1,19 +0,0 @@ -canton { - participants { - participant4 { - storage = ${_shared.storage} - storage.config.properties.databaseName = "participant4" - init.parameters.unique-contract-keys = ${?_.shared.unique-contract-keys} - admin-api { - port = 10042 - // if defined, this include will override the address we bind to. default is 127.0.0.1 - address = ${?_shared.admin-api.address} - } - ledger-api { - port = 10041 - address = ${?_shared.ledger-api.address} - auth-services = ${?_shared.ledger-api.auth-services} - } - } - } -} diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/parameters/nonuck.conf b/canton-3x/community/app/src/pack/examples/03-advanced-configuration/parameters/nonuck.conf deleted file mode 100644 index 5e221d7023..0000000000 --- a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/parameters/nonuck.conf +++ /dev/null @@ -1,3 +0,0 @@ -_shared { - unique-contract-keys = no -} \ No newline at end of file diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/participant-init.canton b/canton-3x/community/app/src/pack/examples/03-advanced-configuration/participant-init.canton deleted file mode 100644 index 86c47327e1..0000000000 --- a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/participant-init.canton +++ /dev/null @@ -1,22 +0,0 @@ - -val participant = participants.local.head - -// only run once -if(participant.domains.list_registered().isEmpty) { - - // connect all local participants to the domain passing a user chosen alias and the domain port as the argument - participants.local.foreach(_.domains.connect("mydomain", "http://localhost:10018")) - - // above connect operation is asynchronous. it is generally at the discretion of the domain - // to decide if a participant can join and when. therefore, we need to asynchronously wait here - // until the participant observes its activation on the domain - utils.retry_until_true { - participant.domains.active("mydomain") - } - // synchronize vetting to ensure the participant has the package needed for the ping - participant.packages.synchronize_vetting() - - // verify that the connection works - participant.health.ping(participant) - -} \ No newline at end of file diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/remote/domain1.conf b/canton-3x/community/app/src/pack/examples/03-advanced-configuration/remote/domain1.conf deleted file mode 100644 index 7ebc36718e..0000000000 --- a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/remote/domain1.conf +++ /dev/null @@ -1,14 +0,0 @@ -canton { - remote-domains { - remoteDomain1 { - public-api { - address = 127.0.0.1 - port = 10018 - } - admin-api { - port = 10019 - address = 127.0.0.1 // default value if omitted - } - } - } -} \ No newline at end of file diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/remote/participant1.conf b/canton-3x/community/app/src/pack/examples/03-advanced-configuration/remote/participant1.conf deleted file mode 100644 index 39dacaef6c..0000000000 --- a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/remote/participant1.conf +++ /dev/null @@ -1,14 +0,0 @@ -canton { - remote-participants { - remoteParticipant1 { - admin-api { - port = 10012 - address = 127.0.0.1 // is the default value if omitted - } - ledger-api { - port = 10011 - address = 127.0.0.1 // is the default value if omitted - } - } - } -} \ No newline at end of file diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/storage/dbinit.py b/canton-3x/community/app/src/pack/examples/03-advanced-configuration/storage/dbinit.py deleted file mode 100644 index c980532f4e..0000000000 --- a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/storage/dbinit.py +++ /dev/null @@ -1,51 +0,0 @@ -#!/usr/bin/python3 -# -# Trivial helper script to create users / databases for Canton nodes -# - -import argparse -import sys - -def get_parser(): - parser = argparse.ArgumentParser(description = "Helper utility to setup Canton databases for a set of nodes") - parser.add_argument("--type", help="Type of database to be setup", choices=["postgres"], default="postgres") - parser.add_argument("--participants", type=int, help="Number of participant dbs to generate (will create dbs named participantX for 1 to N)", default=0) - parser.add_argument("--domains", type=int, help="Number of domain dbs to generate (will create dbs named domainX for 1 to N)", default=0) - parser.add_argument("--sequencers", type=int, help="Number of sequencer dbs to generate (will create dbs named sequencerX for 1 to N", default=0) - parser.add_argument("--mediators", type=int, help="Number of mediators dbs to generate (will create dbs named mediatorX for 1 to N", default=0) - parser.add_argument("--user", type=str, help="Database user name. If given, the script will also generate a SQL command to create the user", required=True) - parser.add_argument("--pwd", type=str, help="Database password") - parser.add_argument("--drop", help="Drop existing", action="store_true") - return parser.parse_args() - -def do_postgres(args): - print(""" -DO -$do$ -BEGIN - IF NOT EXISTS ( - SELECT FROM pg_catalog.pg_roles - WHERE rolname = '%s') THEN - CREATE ROLE \"%s\" LOGIN PASSWORD '%s'; - END IF; -END -$do$; -""" % (args.user, args.user, args.pwd)) - for num, prefix in [(args.domains, "domain"), (args.participants, "participant"), (args.mediators, "mediator"), (args.sequencers, "sequencer")]: - for ii in range(1, num + 1): - dbname = prefix + str(ii) - if args.drop: - print("DROP DATABASE IF EXISTS %s;" % (dbname)) - print("CREATE DATABASE %s;" % dbname) - print("GRANT ALL ON DATABASE %s to \"%s\";" % (dbname, args.user)) - -if __name__ == "__main__": - args = get_parser() - if args.type == "postgres": - do_postgres(args) - else: - raise Exception("Unknown database type %s" % (args.type)) - - - - diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/storage/memory.conf b/canton-3x/community/app/src/pack/examples/03-advanced-configuration/storage/memory.conf deleted file mode 100644 index e40e01c29f..0000000000 --- a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/storage/memory.conf +++ /dev/null @@ -1,5 +0,0 @@ -_shared { - storage { - type = "memory" - } -} \ No newline at end of file diff --git a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/storage/postgres.conf b/canton-3x/community/app/src/pack/examples/03-advanced-configuration/storage/postgres.conf deleted file mode 100644 index 9a1094e42b..0000000000 --- a/canton-3x/community/app/src/pack/examples/03-advanced-configuration/storage/postgres.conf +++ /dev/null @@ -1,37 +0,0 @@ -# Postgres persistence configuration mixin -# -# This file defines a shared configuration resources. You can mix it into your configuration by -# refer to the shared storage resource and add the database name. -# -# Example: -# participant1 { -# storage = ${_shared.storage} -# storage.config.properties.databaseName = "participant1" -# } -# -# The user and password credentials are set to "canton" and "supersafe". As this is not "supersafe", you might -# want to either change this configuration file or pass the settings in via environment variables. -# -_shared { - storage { - type = postgres - config { - dataSourceClass = "org.postgresql.ds.PGSimpleDataSource" - properties = { - serverName = "localhost" - # the next line will override above "serverName" in case the environment variable POSTGRES_HOST exists - serverName = ${?POSTGRES_HOST} - portNumber = "5432" - portNumber = ${?POSTGRES_PORT} - # the next line will fail configuration parsing if the POSTGRES_USER environment variable is not set - user = ${POSTGRES_USER} - password = ${POSTGRES_PASSWORD} - } - } - // If defined, will configure the number of database connections per node. - // Please ensure that your database is setup with sufficient connections. - // If not configured explicitly, every node will create one connection per core on the host machine. This is - // subject to change with future improvements. - parameters.max-connections = ${?POSTGRES_NUM_CONNECTIONS} - } -} diff --git a/canton-3x/community/app/src/pack/examples/06-messaging/README.md b/canton-3x/community/app/src/pack/examples/06-messaging/README.md deleted file mode 100644 index 0edacffd82..0000000000 --- a/canton-3x/community/app/src/pack/examples/06-messaging/README.md +++ /dev/null @@ -1,141 +0,0 @@ -# Messaging via the global domain - -*** -WARNING: The global Canton domain is currently not running. This example does not work at the moment. -You need to start your own Canton domain and set the environment variable canton-examples.domain-url -to the URL of your domain. -*** -TODO(#7564) Make this example work again once the global domain is up -*** - -Participants require a domain to communicate with each other. Digital -Asset is running a generally available global Canton domain -(Canton.Global). Any participant can decide to connect to the global -domain and use it for bilateral communication. - -The messaging example provides a simple messaging application via the -global domain. - -The example is structured as follows: - -``` - . - |-- message Daml model for messages - | |- .daml/dist/message-0.0.1.dar Compiled DAR file - | |- daml/Message.daml Daml source code for messages - | |- daml.yaml Daml configuration file - | |- frontend-config.js Configuration file for Daml Navigator - | - |-- contact Daml model for contacts - | |- daml/Contact.daml Incomplete Daml source code for contacts - | |- daml/Contact.solution Example solution for the Daml exercise below - | |- daml.yaml Daml configuration file - | |- frontend-config.js Configuration file for Daml Navigator - | - |-- canton.conf Configuration file for one participant - |-- init.canton Initialization script for Canton -``` - -The files in `message` must not be changed because it defines the -format of messages to be exchanged. So `message-0.0.1.dar` must be -the same on all participants that want to exchange messages. - - -Run the application by performing the following steps: - -1. Compile the contact model by issuing the command `daml build` in - the `contact` folder. This should generate the file - `contact/.daml/dist/contact-0.0.1.dar`. - -2. Start Canton from the `06-messaging` folder with the following command - - ``` - ../../bin/canton -c canton.conf --bootstrap init.canton - ``` - - If you have never connected to the global domain before, you will - be shown the terms of service for using the global domain. You will - have to accept it once in order to be able to use it. - - Next, you will be asked for your username in the messaging - application. Canton usernames may contain only letters, numbers, - `-` and `_` and may not be longer than 189. Canton will suffix your - username to make it globally unique. Your suffixed user name will - be output on the screen. - - You can set the username in the Java system property - `canton-examples.username` as a command-line argument: - - ``` - ../../bin/canton -c canton.conf --bootstrap init.canton -Dcanton-examples.username=Alice - ``` - -3. Start Daml Navigator. - - After step 2, Canton outputs the command that you need to run to - start Daml Navigator. Run the command in a separate terminal from - the `contact` folder. Typically, the command looks as follows: - - ``` - daml navigator server localhost 7011 -t wallclock --port 7015 -c ui-backend-participant1.conf - ``` - - This will start the frontend on port 7015. - -4. Open a browser and point it to `http://localhost:7015`. - Login with your chosen username. - -5. Find someone else whom you want to send a message. You can search - for usernames with the following command in the Canton console: - - ``` - findUser("Alice") - ``` - - This will list all suffixed usernames that contain the string - `Alice`. Note that these users need not be currently online. - - Click on the `Message:Message` template in the `Templates` view of - Navigator to create a new message. Put your suffixed username as - `sender` and the recipient's suffixed username as `receiver`. - - Click `Submit` to send the message. A `Message:Message` contract - should soon be shown in the `Contracts` table as well as under `Sent`. - - The receiver can use the `Reply` choice to send a message back. - - Stop Canton and Navigator after that. - - Note: Canton is configured to run with a file-based database. - Your username suffix and the messages will be persisted - on your computer in the file `participant1.mv.db`. - Delete this file if you want to start afresh. - -6. Extend the `Contact` Daml model. As is, you must specify suffixed - username of yourself and your contact whenever you send a new - message. The `Contact` template in `contact/daml/Contact.daml` - can store these usernames, but it does not have any choices yet. - - Add a non-consuming choice `Send` to the `Contact` template that - takes a message as parameter. It shall create a `Message` with - `myself` as sender, `other` as recipient, and the given message. - - Write a script to test the message sending via a `Contact` contract - and run the script in Daml studio. - - Compile the extended `Contact` Daml model by running `daml build` - in the `contact` folder. - -7. Restart Canton and Navigator as described in Step 5. - You will be shown a reminder of your suffixed user name - instead of being asked for one. - - Create a `Contact` contract for your counterparty. - Use the `Send` choice on the `Contact` to send a message. - - Since you have modified the `Contact` template, there will be now - several `Contact` templates in the `Templates` tab; one for each - version. Your existing `Contact` contracts refer to the old - version and therefore do not offer the `Send` choice. You would - have to explicitly upgrade the contracts; this process is explained - in the Daml documentation at https://docs.daml.com/upgrade/index.html. diff --git a/canton-3x/community/app/src/pack/examples/06-messaging/canton.conf b/canton-3x/community/app/src/pack/examples/06-messaging/canton.conf deleted file mode 100644 index f388aa5051..0000000000 --- a/canton-3x/community/app/src/pack/examples/06-messaging/canton.conf +++ /dev/null @@ -1,22 +0,0 @@ -canton { - participants { - participant1 { - admin-api { - port= 7012 - } - ledger-api { - port = 7011 - } - storage { - type = "h2" - config = { - connectionPool = disabled - url = "jdbc:h2:file:./participant1;MODE=PostgreSQL;LOCK_TIMEOUT=10000;DB_CLOSE_DELAY=-1" - user = "participant1" - password = "morethansafe" - driver = org.h2.Driver - } - } - } - } -} diff --git a/canton-3x/community/app/src/pack/examples/06-messaging/contact/.gitignore b/canton-3x/community/app/src/pack/examples/06-messaging/contact/.gitignore deleted file mode 100644 index 6250c7b91b..0000000000 --- a/canton-3x/community/app/src/pack/examples/06-messaging/contact/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/.daml -ui-backend-participant1.conf diff --git a/canton-3x/community/app/src/pack/examples/06-messaging/contact/daml.yaml b/canton-3x/community/app/src/pack/examples/06-messaging/contact/daml.yaml deleted file mode 100644 index d80087655c..0000000000 --- a/canton-3x/community/app/src/pack/examples/06-messaging/contact/daml.yaml +++ /dev/null @@ -1,14 +0,0 @@ -sdk-version: 2.9.0-snapshot.20231215.12512.0.v5a0f0a18 -build-options: -- --target=2.1 -sandbox-options: -- --wall-clock-time -name: contact -data-dependencies: -- ../message/.daml/dist/message-0.0.1.dar -source: daml -version: 0.0.1 -dependencies: -- daml-prim -- daml-stdlib -- daml3-script diff --git a/canton-3x/community/app/src/pack/examples/06-messaging/contact/daml/Contact.daml b/canton-3x/community/app/src/pack/examples/06-messaging/contact/daml/Contact.daml deleted file mode 100644 index 6c5741d3d8..0000000000 --- a/canton-3x/community/app/src/pack/examples/06-messaging/contact/daml/Contact.daml +++ /dev/null @@ -1,13 +0,0 @@ --- Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. --- SPDX-License-Identifier: Apache-2.0 - -module Contact where - -import Message - -template Contact - with - myself : Party - other : Party - where - signatory myself diff --git a/canton-3x/community/app/src/pack/examples/06-messaging/contact/daml/Contact.solution b/canton-3x/community/app/src/pack/examples/06-messaging/contact/daml/Contact.solution deleted file mode 100644 index ed0e0390ff..0000000000 --- a/canton-3x/community/app/src/pack/examples/06-messaging/contact/daml/Contact.solution +++ /dev/null @@ -1,35 +0,0 @@ -module Contact where - -import Daml.Script -import Message - -template Contact - with - myself : Party - other : Party - where - signatory myself - - nonconsuming choice Send: () - with - message: Text - controller myself - do - create Message with - sender = myself - receiver = other - message = message - pure () - -contactTest = script do - alice <- allocateParty "alice" - bob <- allocateParty "bob" - - contact <- submit alice do - createCmd Contact with myself = alice; other = bob - - submit alice do - exerciseCmd contact Send with message = "Hi Bob!" - - submit alice do - exerciseCmd contact Send with message = "How are you doing?" diff --git a/canton-3x/community/app/src/pack/examples/06-messaging/contact/frontend-config.js b/canton-3x/community/app/src/pack/examples/06-messaging/contact/frontend-config.js deleted file mode 100644 index b1220ab35a..0000000000 --- a/canton-3x/community/app/src/pack/examples/06-messaging/contact/frontend-config.js +++ /dev/null @@ -1,184 +0,0 @@ -// Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { DamlLfValue } from '@da/ui-core'; - -export const version = { - schema: 'navigator-config', - major: 2, - minor: 0, -}; - -export const customViews = (userId, party, role) => ({ - sent: { - type: "table-view", - title: "Sent", - source: { - type: "contracts", - filter: [ - { - field: "argument.sender", - value: party, - }, - { - field: "template.id", - value: "Message:Message", - } - ], - search: "", - sort: [ - { - field: "id", - direction: "ASCENDING" - } - ] - }, - columns: [ - { - key: "id", - title: "Contract ID", - createCell: ({rowData}) => ({ - type: "text", - value: rowData.id - }), - sortable: true, - width: 80, - weight: 0, - alignment: "left" - }, - { - key: "argument.receiver", - title: "To", - createCell: ({rowData}) => ({ - type: "text", - value: DamlLfValue.toJSON(rowData.argument).receiver - }), - sortable: true, - width: 80, - weight: 0, - alignment: "left" - }, - { - key: "argument.message", - title: "Message", - createCell: ({rowData}) => ({ - type: "text", - value: DamlLfValue.toJSON(rowData.argument).message - }), - sortable: true, - width: 80, - weight: 0, - alignment: "left" - } - ] - }, - received: { - type: "table-view", - title: "Received", - source: { - type: "contracts", - filter: [ - { - field: "argument.receiver", - value: party, - }, - { - field: "template.id", - value: "Message:Message", - } - ], - search: "", - sort: [ - { - field: "id", - direction: "ASCENDING" - } - ] - }, - columns: [ - { - key: "id", - title: "Contract ID", - createCell: ({rowData}) => ({ - type: "text", - value: rowData.id - }), - sortable: true, - width: 80, - weight: 0, - alignment: "left" - }, - { - key: "argument.sender", - title: "From", - createCell: ({rowData}) => ({ - type: "text", - value: DamlLfValue.toJSON(rowData.argument).sender - }), - sortable: true, - width: 80, - weight: 0, - alignment: "left" - }, - { - key: "argument.message", - title: "Message", - createCell: ({rowData}) => ({ - type: "text", - value: DamlLfValue.toJSON(rowData.argument).message - }), - sortable: true, - width: 80, - weight: 0, - alignment: "left" - } - ] - }, - contacts: { - type: "table-view", - title: "Contacts", - source: { - type: "contracts", - filter: [ - { - field: "template.id", - value: "Contact:Contact", - } - ], - search: "", - sort: [ - { - field: "id", - direction: "ASCENDING" - } - ] - }, - columns: [ - { - key: "id", - title: "Contract ID", - createCell: ({rowData}) => ({ - type: "text", - value: rowData.id - }), - sortable: true, - width: 80, - weight: 0, - alignment: "left" - }, - { - key: "argument.other", - title: "Contact", - createCell: ({rowData}) => ({ - type: "text", - value: DamlLfValue.toJSON(rowData.argument).other - }), - sortable: true, - width: 80, - weight: 0, - alignment: "left" - } - ] - } - -}) diff --git a/canton-3x/community/app/src/pack/examples/06-messaging/init.canton b/canton-3x/community/app/src/pack/examples/06-messaging/init.canton deleted file mode 100644 index 1320bbc4ca..0000000000 --- a/canton-3x/community/app/src/pack/examples/06-messaging/init.canton +++ /dev/null @@ -1,58 +0,0 @@ -nodes.local.start() - -val domainUrl = sys.props.get("canton-examples.domain-url").getOrElse("https://canton.global") - -val myself = participant1 - -if (myself.domains.list_registered().length == 0) { - myself.domains.connect("global", domainUrl) -} - -utils.retry_until_true(timeout = 60.seconds) { - myself.domains.active("global") -} - -myself.health.ping(myself) // make sure that the connection works - -// upload the dars - -import better.files._ - -val baseDir = sys.props.get("canton-examples.base-dir").getOrElse(".") -val messageDar: File = baseDir / "message" / ".daml" / "dist" / "message-0.0.1.dar" -val contactDar: File = baseDir / "contact" / ".daml" / "dist" / "contact-0.0.1.dar" -assert(messageDar.exists(), s"Message dar $messageDar isn't built") -assert(contactDar.exists(), s"Contact dar $contactDar isn't build") -myself.dars.upload(messageDar.pathAsString) -myself.dars.upload(contactDar.pathAsString) - -// if no parties have been onboarded, ask for the party name -val hostedParties = myself.parties.hosted() -if (hostedParties.length <= 1) { - val username = sys.props.get("canton-examples.username").getOrElse { - scala.io.StdIn.readLine("Enter the name under which you want to be found: ") - } - val user = myself.parties.enable(username, waitForDomain = DomainChoice.All) - println(s"Your suffixed user name is: ${user.toLf}\n") -} else { - val users = hostedParties.map(_.party.toLf).mkString("\n ") - println(s"Local user names:\n $users") -} - -val messageConf: File = baseDir / "message" / "ui-backend-participant1.conf" -utils.generate_navigator_conf(myself, Some(messageConf.toString)) -val contactConf: File = baseDir / "contact" / "ui-backend-participant1.conf" -utils.generate_navigator_conf(myself, Some(contactConf.toString)) - -println(s"Start Daml Navigator now with the following command from the messaging or contact folder:") -val ledgerApiPort = myself.config.clientLedgerApi.port -println(s"\n daml navigator server localhost $ledgerApiPort -t wallclock --port 7015 -c ui-backend-participant1.conf\n") - -def findUser(name: String): Unit = { - val users = myself.parties.list(filterParty = name).map(_.party.toLf) - println(users.mkString("\n")) -} - -println(s"You can search for other users with the following query in this Canton console:") -println("\n findUser(\"Alice\")\n") -println("If you want to send them a message, copy their user name into the receiver field.\n") diff --git a/canton-3x/community/app/src/pack/examples/06-messaging/message/.gitignore b/canton-3x/community/app/src/pack/examples/06-messaging/message/.gitignore deleted file mode 100644 index 6250c7b91b..0000000000 --- a/canton-3x/community/app/src/pack/examples/06-messaging/message/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/.daml -ui-backend-participant1.conf diff --git a/canton-3x/community/app/src/pack/examples/06-messaging/message/daml.yaml b/canton-3x/community/app/src/pack/examples/06-messaging/message/daml.yaml deleted file mode 100644 index 4cbd0d04e4..0000000000 --- a/canton-3x/community/app/src/pack/examples/06-messaging/message/daml.yaml +++ /dev/null @@ -1,12 +0,0 @@ -sdk-version: 2.9.0-snapshot.20231215.12512.0.v5a0f0a18 -build-options: - - --target=2.1 -sandbox-options: -- --wall-clock-time -name: message -source: daml -version: 0.0.1 -dependencies: -- daml-prim -- daml-stdlib -- daml3-script diff --git a/canton-3x/community/app/src/pack/examples/06-messaging/message/daml/Message.daml b/canton-3x/community/app/src/pack/examples/06-messaging/message/daml/Message.daml deleted file mode 100644 index bc8403eeea..0000000000 --- a/canton-3x/community/app/src/pack/examples/06-messaging/message/daml/Message.daml +++ /dev/null @@ -1,58 +0,0 @@ --- Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. --- SPDX-License-Identifier: Apache-2.0 - -module Message where - -import Daml.Script - -template Message - with - sender : Party - receiver : Party - message : Text - where - signatory sender - observer receiver - - choice Retract: () - controller sender - do - pure () - - - nonconsuming choice Reply: () - with - reply: Text - controller receiver - do - create Message with - sender = receiver - receiver = sender - message = reply - pure () - - -messaging = script do - alice <- allocateParty "Alice" - bob <- allocateParty "Bob" - - submit alice do - createCmd Message with - sender = alice - receiver = bob - message = "Hi Bob!" - - submit bob do - createCmd Message with - sender = bob - receiver = alice - message = "Hi Alice! How are you doing?" - - anotherMsg <- submit alice do - createCmd Message with - sender = alice - receiver = bob - message = "Another message" - - submit alice do - exerciseCmd anotherMsg Retract diff --git a/canton-3x/community/app/src/pack/examples/06-messaging/message/frontend-config.js b/canton-3x/community/app/src/pack/examples/06-messaging/message/frontend-config.js deleted file mode 100644 index 76626857a3..0000000000 --- a/canton-3x/community/app/src/pack/examples/06-messaging/message/frontend-config.js +++ /dev/null @@ -1,137 +0,0 @@ -// Copyright (c) 2020 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { DamlLfValue } from '@da/ui-core'; - -export const version = { - schema: 'navigator-config', - major: 2, - minor: 0, -}; - -export const customViews = (userId, party, role) => ({ - sent: { - type: "table-view", - title: "Sent", - source: { - type: "contracts", - filter: [ - { - field: "argument.sender", - value: party, - }, - { - field: "template.id", - value: "Message:Message", - } - ], - search: "", - sort: [ - { - field: "id", - direction: "ASCENDING" - } - ] - }, - columns: [ - { - key: "id", - title: "Contract ID", - createCell: ({rowData}) => ({ - type: "text", - value: rowData.id - }), - sortable: true, - width: 80, - weight: 0, - alignment: "left" - }, - { - key: "argument.receiver", - title: "To", - createCell: ({rowData}) => ({ - type: "text", - value: DamlLfValue.toJSON(rowData.argument).receiver - }), - sortable: true, - width: 80, - weight: 0, - alignment: "left" - }, - { - key: "argument.message", - title: "Message", - createCell: ({rowData}) => ({ - type: "text", - value: DamlLfValue.toJSON(rowData.argument).message - }), - sortable: true, - width: 80, - weight: 0, - alignment: "left" - } - ] - }, - received: { - type: "table-view", - title: "Received", - source: { - type: "contracts", - filter: [ - { - field: "argument.receiver", - value: party, - }, - { - field: "template.id", - value: "Message:Message", - } - ], - search: "", - sort: [ - { - field: "id", - direction: "ASCENDING" - } - ] - }, - columns: [ - { - key: "id", - title: "Contract ID", - createCell: ({rowData}) => ({ - type: "text", - value: rowData.id - }), - sortable: true, - width: 80, - weight: 0, - alignment: "left" - }, - { - key: "argument.sender", - title: "From", - createCell: ({rowData}) => ({ - type: "text", - value: DamlLfValue.toJSON(rowData.argument).sender - }), - sortable: true, - width: 80, - weight: 0, - alignment: "left" - }, - { - key: "argument.message", - title: "Message", - createCell: ({rowData}) => ({ - type: "text", - value: DamlLfValue.toJSON(rowData.argument).message - }), - sortable: true, - width: 80, - weight: 0, - alignment: "left" - } - ] - } -}) diff --git a/canton-3x/community/app/src/pack/examples/07-repair/README.md b/canton-3x/community/app/src/pack/examples/07-repair/README.md index 6ea6daa633..c9f1aca7fb 100644 --- a/canton-3x/community/app/src/pack/examples/07-repair/README.md +++ b/canton-3x/community/app/src/pack/examples/07-repair/README.md @@ -16,6 +16,23 @@ To set up this scenario, run ``` ../../bin/canton -Dcanton-examples.dar-path=../../dars/CantonExamples.dar \ -c participant1.conf,participant2.conf,domain-repair-lost.conf,domain-repair-new.conf \ - -c ../03-advanced-configuration/storage/h2.conf,enable-preview-commands.conf \ + -c ../../config/storage/h2.conf,enable-preview-commands.conf \ --bootstrap domain-repair-init.canton ``` + +## 2. [Importing contracts to Canton](https://docs.daml.com/canton/usermanual/repairing.html#importing-existing-contracts) + +depends on files: +- Participant configurations: participant1.conf, participant2.conf, participant3.conf for the "import ledger", and participant4.conf for the "export ledger" +- Domain configurations: domain-export-ledger.conf and domain-import-ledger.conf for the export and import ledgers respectively +- enable-preview-commands.conf to enable "preview" and "repair" commands +- Initialization script: import-ledger-init.canton that populates the export ledger with Paint agreement and Iou contracts + +To set up this scenario, run + +``` + ../../bin/canton -Dcanton-examples.dar-path=../../dars/CantonExamples.dar \ + -c participant1.conf,participant2.conf,participant3.conf,participant4.conf,domain-export-ledger.conf,domain-import-ledger.conf \ + -c ../../config/storage/h2.conf,enable-preview-commands.conf \ + --bootstrap import-ledger-init.canton +``` diff --git a/canton-3x/community/app/src/test/resources/advancedConfDef.env b/canton-3x/community/app/src/test/resources/advancedConfDef.env index f01f314075..e42a66860e 100644 --- a/canton-3x/community/app/src/test/resources/advancedConfDef.env +++ b/canton-3x/community/app/src/test/resources/advancedConfDef.env @@ -1,4 +1,4 @@ -POSTGRES_PASSWORD=supersafe -POSTGRES_USER=canton +TLS_CERT_LOCATION=enterprise/app/src/test/resources/tls JWT_URL="https://bla.fasel/jwks.key" -JWT_CERTIFICATE_FILE="community/app/src/test/resources/dummy.crt" \ No newline at end of file +JWT_CERTIFICATE_FILE="community/app/src/test/resources/dummy.crt" +POSTGRES_PASSWORD="supersafe" diff --git a/canton-3x/community/app/src/test/resources/distributed-single-domain.conf b/canton-3x/community/app/src/test/resources/distributed-single-domain.conf index cb2fdf86e5..a4da723b00 100644 --- a/canton-3x/community/app/src/test/resources/distributed-single-domain.conf +++ b/canton-3x/community/app/src/test/resources/distributed-single-domain.conf @@ -2,6 +2,10 @@ canton { features.enable-testing-commands = yes + sequencers-x { + sequencer1 { } + } + mediators-x { mediator1 { } } diff --git a/canton-3x/community/app/src/test/resources/documentation-snippets/large-in-memory-fan-out.conf b/canton-3x/community/app/src/test/resources/documentation-snippets/large-in-memory-fan-out.conf new file mode 100644 index 0000000000..cb0d675a6f --- /dev/null +++ b/canton-3x/community/app/src/test/resources/documentation-snippets/large-in-memory-fan-out.conf @@ -0,0 +1,3 @@ +canton.participants.participant.ledger-api.index-service { + max-transactions-in-memory-fan-out-buffer-size = 10000 // default 1000 +} diff --git a/canton-3x/community/app/src/test/resources/documentation-snippets/large-ledger-api-cache.conf b/canton-3x/community/app/src/test/resources/documentation-snippets/large-ledger-api-cache.conf new file mode 100644 index 0000000000..a65f103fc3 --- /dev/null +++ b/canton-3x/community/app/src/test/resources/documentation-snippets/large-ledger-api-cache.conf @@ -0,0 +1,4 @@ +canton.participants.participant.ledger-api.index-service { + max-contract-state-cache-size = 100000 // default 1e4 + max-contract-key-state-cache-size = 100000 // default 1e4 +} diff --git a/canton-3x/community/app/src/test/resources/documentation-snippets/leeway-parameters.conf b/canton-3x/community/app/src/test/resources/documentation-snippets/leeway-parameters.conf new file mode 100644 index 0000000000..72263b3ead --- /dev/null +++ b/canton-3x/community/app/src/test/resources/documentation-snippets/leeway-parameters.conf @@ -0,0 +1,6 @@ +canton.participants.participant.parameters.ledger-api-server-parameters.jwt-timestamp-leeway { + default = 5 + expires-at = 10 + issued-at = 15 + not-before = 20 +} diff --git a/canton-3x/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ExampleIntegrationTest.scala b/canton-3x/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ExampleIntegrationTest.scala index 4694c21753..4cdf6e3e96 100644 --- a/canton-3x/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ExampleIntegrationTest.scala +++ b/canton-3x/community/app/src/test/scala/com/digitalasset/canton/integration/tests/ExampleIntegrationTest.scala @@ -12,8 +12,8 @@ import com.digitalasset.canton.integration.CommunityTests.{ IsolatedCommunityEnvironments, } import com.digitalasset.canton.integration.tests.ExampleIntegrationTest.{ - advancedConfiguration, ensureSystemProperties, + referenceConfiguration, repairConfiguration, simpleTopology, } @@ -60,9 +60,8 @@ trait HasConsoleScriptRunner { this: NamedLogging => object ExampleIntegrationTest { lazy val examplesPath: File = "community" / "app" / "src" / "pack" / "examples" lazy val simpleTopology: File = examplesPath / "01-simple-topology" - lazy val advancedConfiguration: File = examplesPath / "03-advanced-configuration" + lazy val referenceConfiguration: File = "community" / "app" / "src" / "pack" / "config" lazy val composabilityConfiguration: File = examplesPath / "05-composability" - lazy val messagingConfiguration: File = examplesPath / "06-messaging" lazy val repairConfiguration: File = examplesPath / "07-repair" lazy val advancedConfTestEnv: File = "community" / "app" / "src" / "test" / "resources" / "advancedConfDef.env" @@ -101,7 +100,7 @@ class SimplePingExampleIntegrationTest class RepairExampleIntegrationTest extends ExampleIntegrationTest( - advancedConfiguration / "storage" / "h2.conf", + referenceConfiguration / "storage" / "h2.conf", repairConfiguration / "domain-repair-lost.conf", repairConfiguration / "domain-repair-new.conf", repairConfiguration / "participant1.conf", diff --git a/canton-3x/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SimplestPingDistributedCommunityIntegrationTest.scala b/canton-3x/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SimplestPingXCommunityIntegrationTest.scala similarity index 54% rename from canton-3x/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SimplestPingDistributedCommunityIntegrationTest.scala rename to canton-3x/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SimplestPingXCommunityIntegrationTest.scala index 27aa02301a..0dc8f8984b 100644 --- a/canton-3x/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SimplestPingDistributedCommunityIntegrationTest.scala +++ b/canton-3x/community/app/src/test/scala/com/digitalasset/canton/integration/tests/SimplestPingXCommunityIntegrationTest.scala @@ -3,6 +3,7 @@ package com.digitalasset.canton.integration.tests +import com.digitalasset.canton.console.InstanceReferenceX import com.digitalasset.canton.health.admin.data.NodeStatus import com.digitalasset.canton.integration.CommunityTests.{ CommunityIntegrationTest, @@ -13,7 +14,7 @@ import com.digitalasset.canton.integration.{ CommunityEnvironmentDefinition, } -class SimplestPingDistributedCommunityIntegrationTest +class SimplestPingXCommunityIntegrationTest extends CommunityIntegrationTest with SharedCommunityEnvironment { override def environmentDefinition: CommunityEnvironmentDefinition = @@ -25,16 +26,28 @@ class SimplestPingDistributedCommunityIntegrationTest "we can run a trivial ping" in { implicit env => import env.* + sequencer1x.start() mediator1x.start() - participants.local.start() - + sequencer1x.health.status shouldBe NodeStatus.NotInitialized(true) mediator1x.health.status shouldBe NodeStatus.NotInitialized(true) - // TODO(i15178): test this as soon as the mediator can be initialized - // mediator1x.testing.fetch_domain_time() + bootstrap.domain( + "da", + Seq(sequencer1x), + Seq(mediator1x), + Seq[InstanceReferenceX](sequencer1x, mediator1x), + ) - // TODO(i15178): add sequencer and domain manager, then test if the distributed domain can be used for a ping + sequencer1x.health.status shouldBe a[NodeStatus.Success[?]] + mediator1x.health.status shouldBe a[NodeStatus.Success[?]] + + participantsX.local.start() + + // TODO(i16087): comment out as soon as the db sequencer supports group addresses +// participantsX.local.domains.connect_local(sequencer1x) + // mediator1x.testing.fetch_domain_time() // Test if the DomainTimeService works for community mediators as well. + // participant1.health.ping(participant2) } } diff --git a/canton-3x/community/base/src/main/protobuf/com/digitalasset/canton/protocol/v0/participant_transfer.proto b/canton-3x/community/base/src/main/protobuf/com/digitalasset/canton/protocol/v0/participant_transfer.proto index 15a57c9a92..ffe2f24e22 100644 --- a/canton-3x/community/base/src/main/protobuf/com/digitalasset/canton/protocol/v0/participant_transfer.proto +++ b/canton-3x/community/base/src/main/protobuf/com/digitalasset/canton/protocol/v0/participant_transfer.proto @@ -10,6 +10,6 @@ import "google/protobuf/timestamp.proto"; // Messages sent by a participant as part of the transfer protocol message TransferId { - string origin_domain = 1; + string source_domain = 1; google.protobuf.Timestamp timestamp = 2; } diff --git a/canton-3x/community/base/src/main/resources/rewrite-appender.xml b/canton-3x/community/base/src/main/resources/rewrite-appender.xml index 2bcdc62b09..0c011c1ef9 100644 --- a/canton-3x/community/base/src/main/resources/rewrite-appender.xml +++ b/canton-3x/community/base/src/main/resources/rewrite-appender.xml @@ -103,6 +103,14 @@ Flyway upgrade recommended: H2 2.1.210 is newer than this version of Flyway and support has not been tested. INFO + + + + com.zaxxer.hikari.pool.PoolBase + Failed to validate connection + INFO + + diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/config/CacheConfig.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/config/CacheConfig.scala index 6e289b0957..16b06a8a7d 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/config/CacheConfig.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/config/CacheConfig.scala @@ -16,7 +16,7 @@ import scala.concurrent.ExecutionContext */ final case class CacheConfig( maximumSize: PositiveNumeric[Long], - expireAfterAccess: NonNegativeFiniteDuration = NonNegativeFiniteDuration.ofMinutes(10), + expireAfterAccess: NonNegativeFiniteDuration = NonNegativeFiniteDuration.ofMinutes(1), ) { def buildScaffeine()(implicit ec: ExecutionContext): Scaffeine[Any, Any] = diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/config/DomainTimeTrackerConfig.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/config/DomainTimeTrackerConfig.scala index c79de2d884..d499b939c7 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/config/DomainTimeTrackerConfig.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/config/DomainTimeTrackerConfig.scala @@ -4,10 +4,10 @@ package com.digitalasset.canton.config import cats.syntax.option.* +import com.digitalasset.canton.admin.time.v0 import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} import com.digitalasset.canton.serialization.ProtoConverter import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult -import com.digitalasset.canton.time.admin.v0 /** Configuration for the domain time tracker. * @param observationLatency Even if the host and domain clocks are perfectly synchronized there will always be some latency diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/config/ServerConfig.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/config/ServerConfig.scala index 489b7ffd0e..ba48d31150 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/config/ServerConfig.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/config/ServerConfig.scala @@ -6,20 +6,16 @@ package com.digitalasset.canton.config import com.daml.metrics.api.MetricHandle.MetricsFactory import com.daml.metrics.api.MetricName import com.daml.metrics.grpc.GrpcServerMetrics -import com.digitalasset.canton.ProtoDeserializationError import com.digitalasset.canton.config.AdminServerConfig.defaultAddress import com.digitalasset.canton.config.RequireTypes.{ExistingFile, NonNegativeInt, Port} import com.digitalasset.canton.config.SequencerConnectionConfig.CertificateFile -import com.digitalasset.canton.domain.api.v0 import com.digitalasset.canton.ledger.api.tls.TlsVersion import com.digitalasset.canton.logging.NamedLoggerFactory -import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} import com.digitalasset.canton.networking.grpc.{ CantonCommunityServerInterceptors, CantonServerBuilder, CantonServerInterceptors, } -import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult import com.digitalasset.canton.tracing.TracingConfig import io.netty.handler.ssl.{ClientAuth, SslContext} import org.slf4j.LoggerFactory @@ -178,27 +174,6 @@ final case class KeepAliveClientConfig( timeout: NonNegativeFiniteDuration = NonNegativeFiniteDuration.ofSeconds(20), ) -sealed trait ApiType extends PrettyPrinting { - def toProtoEnum: v0.SequencerApiType -} - -object ApiType { - case object Grpc extends ApiType { - def toProtoEnum: v0.SequencerApiType = v0.SequencerApiType.Grpc - override def pretty: Pretty[Grpc.type] = prettyOfObject[Grpc.type] - } - - def fromProtoEnum( - field: String, - apiTypeP: v0.SequencerApiType, - ): ParsingResult[ApiType] = - apiTypeP match { - case v0.SequencerApiType.Grpc => Right(Grpc) - case v0.SequencerApiType.Unrecognized(value) => - Left(ProtoDeserializationError.UnrecognizedEnum(field, value)) - } -} - /** A client configuration to a corresponding server configuration */ final case class ClientConfig( address: String = "127.0.0.1", diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/config/TimeProofRequestConfig.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/config/TimeProofRequestConfig.scala index 110084467d..f104e4f81a 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/config/TimeProofRequestConfig.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/config/TimeProofRequestConfig.scala @@ -4,10 +4,10 @@ package com.digitalasset.canton.config import cats.syntax.option.* +import com.digitalasset.canton.admin.time.v0 import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} import com.digitalasset.canton.serialization.ProtoConverter import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult -import com.digitalasset.canton.time.admin.v0 /** @param initialRetryDelay The initial retry delay if the request to send a sequenced event fails * @param maxRetryDelay The max retry delay if the request to send a sequenced event fails diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/crypto/Encryption.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/crypto/Encryption.scala index 5014882dfe..9a3df775ef 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/crypto/Encryption.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/crypto/Encryption.scala @@ -6,6 +6,7 @@ package com.digitalasset.canton.crypto import cats.Order import cats.data.EitherT import cats.instances.future.* +import com.daml.nonempty.NonEmpty import com.digitalasset.canton.ProtoDeserializationError import com.digitalasset.canton.config.CantonRequireTypes.String68 import com.digitalasset.canton.crypto.store.{ @@ -224,6 +225,13 @@ object EncryptionKeyScheme { v0.EncryptionKeyScheme.Rsa2048OaepSha256 } + val allSchemes: NonEmpty[Set[EncryptionKeyScheme]] = NonEmpty.mk( + Set, + EciesP256HkdfHmacSha256Aes128Gcm, + EciesP256HmacSha256Aes128Cbc, + Rsa2048OaepSha256, + ) + def fromProtoEnum( field: String, schemeP: v0.EncryptionKeyScheme, diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/crypto/Signing.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/crypto/Signing.scala index 2d89a0b0c3..adf2cee554 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/crypto/Signing.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/crypto/Signing.scala @@ -238,6 +238,8 @@ object SigningKeyScheme { val EdDsaSchemes: NonEmpty[Set[SigningKeyScheme]] = NonEmpty.mk(Set, Ed25519) val EcDsaSchemes: NonEmpty[Set[SigningKeyScheme]] = NonEmpty.mk(Set, EcDsaP256, EcDsaP384) + val allSchemes: NonEmpty[Set[SigningKeyScheme]] = NonEmpty.mk(Set, Ed25519, EcDsaP256, EcDsaP384) + def fromProtoEnum( field: String, schemeP: v0.SigningKeyScheme, diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/lifecycle/FutureUnlessShutdown.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/lifecycle/FutureUnlessShutdown.scala index a334b55fb8..f20e923721 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/lifecycle/FutureUnlessShutdown.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/lifecycle/FutureUnlessShutdown.scala @@ -6,6 +6,8 @@ package com.digitalasset.canton.lifecycle import cats.arrow.FunctionK import cats.data.EitherT import cats.{Applicative, FlatMap, Functor, Id, Monad, MonadThrow, Monoid, Parallel, ~>} +import com.daml.metrics.Timed +import com.daml.metrics.api.MetricHandle.Timer import com.digitalasset.canton.logging.ErrorLoggingContext import com.digitalasset.canton.util.Thereafter.syntax.* import com.digitalasset.canton.util.{LoggerUtil, Thereafter} @@ -342,4 +344,9 @@ object FutureUnlessShutdownImpl { ): EitherT[FutureUnlessShutdown, A, B] = EitherT(eitherT.value.tapOnShutdown(f)) } + + implicit class TimerOnShutdownSyntax(private val timed: Timed.type) extends AnyVal { + def future[T](timer: Timer, future: => FutureUnlessShutdown[T]): FutureUnlessShutdown[T] = + FutureUnlessShutdown(timed.future(timer, future.unwrap)) + } } diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/logging/pretty/PrettyInstances.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/logging/pretty/PrettyInstances.scala index 8cc385db5b..c0a1242942 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/logging/pretty/PrettyInstances.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/logging/pretty/PrettyInstances.scala @@ -292,6 +292,13 @@ trait PrettyInstances { param("transactionId", _.transactionId.singleQuoted, _.transactionId.nonEmpty), ) + implicit def prettyCompletionV2: Pretty[com.daml.ledger.api.v2.completion.Completion] = + prettyOfClass( + unnamedParamIfDefined(_.status), + param("commandId", _.commandId.singleQuoted), + param("updateId", _.updateId.singleQuoted, _.updateId.nonEmpty), + ) + implicit def prettyRpcStatus: Pretty[com.google.rpc.status.Status] = prettyOfClass( customParam(rpcStatus => Status.fromCodeValue(rpcStatus.code).getCode.toString), diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/protocol/Tags.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/protocol/Tags.scala index 4b43571efd..e43ad22e9d 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/protocol/Tags.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/protocol/Tags.scala @@ -188,7 +188,13 @@ final case class TransferId(sourceDomain: SourceDomainId, transferOutTimestamp: extends PrettyPrinting { def toProtoV0: v0.TransferId = v0.TransferId( - originDomain = sourceDomain.toProtoPrimitive, + sourceDomain = sourceDomain.toProtoPrimitive, + timestamp = Some(transferOutTimestamp.toProtoPrimitive), + ) + + def toAdminProto: com.digitalasset.canton.admin.participant.v0.TransferId = + com.digitalasset.canton.admin.participant.v0.TransferId( + sourceDomain = sourceDomain.toProtoPrimitive, timestamp = Some(transferOutTimestamp.toProtoPrimitive), ) @@ -208,12 +214,25 @@ object TransferId { def fromProtoV0(transferIdP: v0.TransferId): ParsingResult[TransferId] = transferIdP match { - case v0.TransferId(sourceDomainP, requestTimestampP) => + case v0.TransferId(originDomainP, requestTimestampP) => for { - sourceDomain <- DomainId.fromProtoPrimitive(sourceDomainP, "TransferId.origin_domain") + sourceDomain <- DomainId.fromProtoPrimitive(originDomainP, "TransferId.origin_domain") requestTimestamp <- ProtoConverter .required("TransferId.timestamp", requestTimestampP) .flatMap(CantonTimestamp.fromProtoPrimitive) } yield TransferId(SourceDomainId(sourceDomain), requestTimestamp) } + + def fromAdminProtoV0( + transferIdP: com.digitalasset.canton.admin.participant.v0.TransferId + ): ParsingResult[TransferId] = + transferIdP match { + case com.digitalasset.canton.admin.participant.v0.TransferId(sourceDomainP, requestTsP) => + for { + sourceDomain <- DomainId.fromProtoPrimitive(sourceDomainP, "TransferId.source_domain") + requestTimestamp <- ProtoConverter + .required("TransferId.timestamp", requestTsP) + .flatMap(CantonTimestamp.fromProtoPrimitive) + } yield TransferId(SourceDomainId(sourceDomain), requestTimestamp) + } } diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/ApplicationHandlerPekko.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/ApplicationHandlerPekko.scala new file mode 100644 index 0000000000..c2a166891d --- /dev/null +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/ApplicationHandlerPekko.scala @@ -0,0 +1,200 @@ +// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.sequencing + +import cats.syntax.foldable.* +import com.daml.metrics.Timed +import com.daml.nonempty.NonEmpty +import com.digitalasset.canton.SequencerCounter +import com.digitalasset.canton.config.RequireTypes.PositiveInt +import com.digitalasset.canton.lifecycle.UnlessShutdown.{AbortedDueToShutdown, Outcome} +import com.digitalasset.canton.lifecycle.{CloseContext, FutureUnlessShutdown, UnlessShutdown} +import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} +import com.digitalasset.canton.metrics.SequencerClientMetrics +import com.digitalasset.canton.resource.DbStorage.PassiveInstanceException +import com.digitalasset.canton.sequencing.client.SequencerClientSubscriptionError.{ + ApplicationHandlerError, + ApplicationHandlerException, + ApplicationHandlerPassive, +} +import com.digitalasset.canton.sequencing.protocol.ClosedEnvelope +import com.digitalasset.canton.store.SequencedEventStore.PossiblyIgnoredSequencedEvent +import com.digitalasset.canton.tracing.{TraceContext, Traced} +import com.digitalasset.canton.util.PekkoUtil.syntax.* +import com.digitalasset.canton.util.SingletonTraverse +import com.digitalasset.canton.util.SingletonTraverse.syntax.* +import org.apache.pekko.NotUsed +import org.apache.pekko.stream.KillSwitch +import org.apache.pekko.stream.scaladsl.Flow + +import scala.concurrent.ExecutionContext +import scala.util.{Failure, Success, Try} + +/** Converts an [[com.digitalasset.canton.sequencing.ApplicationHandler]] into a Pekko flow. */ +class ApplicationHandlerPekko[F[+_], Context]( + handler: PossiblyIgnoredApplicationHandler[ClosedEnvelope], + metrics: SequencerClientMetrics, + override protected val loggerFactory: NamedLoggerFactory, + killSwitchOfContext: Context => KillSwitch, +)(implicit executionContext: ExecutionContext, Context: SingletonTraverse.Aux[F, Context]) + extends NamedLogging { + import ApplicationHandlerPekko.* + + /** Calls the `handler` sequentially for each sequenced event, + * and stops if synchronous processing throws an exception. + * `Control` elements are passed through. + * + * @param asyncParallelism How many asynchronous processing steps are run concurrently. + */ + def asFlow( + asyncParallelism: PositiveInt + )(implicit traceContext: TraceContext, closeContext: CloseContext): Flow[ + F[BoxedEnvelope[PossiblyIgnoredEnvelopeBox, ClosedEnvelope]], + F[UnlessShutdown[Either[ApplicationHandlerError, Unit]]], + NotUsed, + ] = { + Flow[F[BoxedEnvelope[PossiblyIgnoredEnvelopeBox, ClosedEnvelope]]].contextualize + .statefulMapAsyncContextualizedUS(KeepGoing: State)(processSynchronously) + // do not use mapAsyncUS because the asynchronous futures have already been spawned + // by the synchronous processing + // and we want to synchronize on all of them upon a shutdown + // The declared parallelism nevertheless limits the number of asynchronous processing running in parallel + // via backpressure. + .mapAsync(asyncParallelism.value) { result => + result.traverseSingleton { (context, errorOrSyncResult) => + val asyncResult = errorOrSyncResult match { + case Outcome(errorOrSyncResult) => + errorOrSyncResult match { + case Right(Some(syncResult)) => + processAsyncResult(syncResult, killSwitchOfContext(context)) + case Right(None) => FutureUnlessShutdown.pure(Right(())) + case Left(error) => FutureUnlessShutdown.pure(Left(error)) + } + case AbortedDueToShutdown => FutureUnlessShutdown.abortedDueToShutdown + } + asyncResult.unwrap + } + } + } + + private[this] def processSynchronously( + state: State, + context: Context, + tracedEventBatch: BoxedEnvelope[PossiblyIgnoredEnvelopeBox, ClosedEnvelope], + )(implicit closeContext: CloseContext): FutureUnlessShutdown[ + ( + State, + Either[ApplicationHandlerError, Option[EventBatchSynchronousResult]], + ) + ] = { + + state match { + case KeepGoing => + tracedEventBatch.traverse(NonEmpty.from) match { + case Some(eventBatchNE) => + handleNextBatch(eventBatchNE, killSwitchOfContext(context)) + case None => + FutureUnlessShutdown.pure(KeepGoing -> Right(None)) + } + case Halt => + FutureUnlessShutdown.pure(Halt -> Right(None)) + } + } + + private def handleNextBatch( + tracedBatch: Traced[NonEmpty[Seq[PossiblyIgnoredSequencedEvent[ClosedEnvelope]]]], + killSwitch: KillSwitch, + )(implicit + closeContext: CloseContext + ): FutureUnlessShutdown[ + (State, Either[ApplicationHandlerError, Option[EventBatchSynchronousResult]]) + ] = + tracedBatch.withTraceContext { implicit batchTraceContext => batch => + val lastSc = batch.last1.counter + val firstEvent = batch.head1 + val firstSc = firstEvent.counter + + logger.debug(s"Passing ${batch.size} events to the application handler ${handler.name}.") + // Measure only the synchronous part of the application handler so that we see how much the application handler + // contributes to the sequential processing bottleneck. + val syncResultFF = FutureUnlessShutdown.fromTry( + Try( + Timed.future(metrics.applicationHandle, handler(Traced(batch))) + ) + ) + + syncResultFF.flatten.transformIntoSuccess { + case Success(asyncResultOutcome) => + asyncResultOutcome.map(result => + KeepGoing -> Right(Some(EventBatchSynchronousResult(firstSc, lastSc, result))) + ) + + case Failure(error) => + killSwitch.shutdown() + handleError(error, firstSc, lastSc, syncProcessing = true) + .map(failure => Halt -> Left(failure)) + } + } + + private def processAsyncResult( + syncResult: EventBatchSynchronousResult, + killSwitch: KillSwitch, + )(implicit + closeContext: CloseContext + ): FutureUnlessShutdown[Either[ApplicationHandlerError, Unit]] = { + val EventBatchSynchronousResult(firstSc, lastSc, asyncResult) = syncResult + implicit val batchTraceContext: TraceContext = syncResult.traceContext + asyncResult.unwrap.transformIntoSuccess { + case Success(outcome) => + outcome.map(Right.apply) + case Failure(error) => + killSwitch.shutdown() + handleError(error, firstSc, lastSc, syncProcessing = false).map(failure => Left(failure)) + } + } + + private def handleError( + error: Throwable, + firstSc: SequencerCounter, + lastSc: SequencerCounter, + syncProcessing: Boolean, + )(implicit + traceContext: TraceContext, + closeContext: CloseContext, + ): UnlessShutdown[ApplicationHandlerError] = { + val sync = if (syncProcessing) "Synchronous" else "Asynchronous" + error match { + case PassiveInstanceException(reason) => + logger.warn(s"$sync event processing stopped because instance became passive") + Outcome(ApplicationHandlerPassive(reason)) + + case _ if closeContext.context.isClosing => + logger.info( + s"$sync event processing failed for event batch with sequencer counters $firstSc to $lastSc, most likely due to an ongoing shutdown", + error, + ) + AbortedDueToShutdown + + case _ => + logger.error( + s"Synchronous event processing failed for event batch with sequencer counters $firstSc to $lastSc.", + error, + ) + Outcome(ApplicationHandlerException(error, firstSc, lastSc)) + } + } +} + +object ApplicationHandlerPekko { + + private[ApplicationHandlerPekko] sealed trait State extends Product with Serializable + private[ApplicationHandlerPekko] case object Halt extends State + private[ApplicationHandlerPekko] case object KeepGoing extends State + + private final case class EventBatchSynchronousResult( + firstSc: SequencerCounter, + lastSc: SequencerCounter, + asyncResult: AsyncResult, + )(implicit val traceContext: TraceContext) +} diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/SequencedEventMonotonicityChecker.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/SequencedEventMonotonicityChecker.scala index 243d3cb9c2..f436a33f08 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/SequencedEventMonotonicityChecker.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/SequencedEventMonotonicityChecker.scala @@ -3,6 +3,7 @@ package com.digitalasset.canton.sequencing +import cats.syntax.functor.* import cats.syntax.functorFilter.* import com.digitalasset.canton.SequencerCounter import com.digitalasset.canton.data.CantonTimestamp @@ -32,14 +33,18 @@ class SequencedEventMonotonicityChecker( import SequencedEventMonotonicityChecker.* /** Pekko version of the check. Pulls the kill switch and drains the source when a violation is detected. */ - def flow: Flow[ - WithKillSwitch[OrdinarySerializedEvent], - WithKillSwitch[OrdinarySerializedEvent], + def flow[E]: Flow[ + WithKillSwitch[Either[E, OrdinarySerializedEvent]], + WithKillSwitch[Either[E, OrdinarySerializedEvent]], NotUsed, ] = { - Flow[WithKillSwitch[OrdinarySerializedEvent]] + Flow[WithKillSwitch[Either[E, OrdinarySerializedEvent]]] .statefulMap(() => initialState)( - (state, eventAndKillSwitch) => eventAndKillSwitch.traverse(onNext(state, _)), + (state, eventAndKillSwitch) => + eventAndKillSwitch.traverse { + case left @ Left(_) => state -> Emit(left) + case Right(event) => onNext(state, event).map(_.map(Right(_))) + }, _ => None, ) .mapConcat { actionAndKillSwitch => @@ -84,7 +89,10 @@ class SequencedEventMonotonicityChecker( private def initialState: State = GoodState(firstSequencerCounter, firstTimestampLowerBoundInclusive) - private def onNext(state: State, event: OrdinarySerializedEvent): (State, Action) = state match { + private def onNext( + state: State, + event: OrdinarySerializedEvent, + ): (State, Action[OrdinarySerializedEvent]) = state match { case Failed => (state, Drop) case GoodState(nextSequencerCounter, lowerBoundTimestamp) => val monotonic = @@ -101,18 +109,26 @@ class SequencedEventMonotonicityChecker( object SequencedEventMonotonicityChecker { - private sealed trait Action extends Product with Serializable - private final case class Emit(event: OrdinarySerializedEvent) extends Action - private case object Drop extends Action + private sealed trait Action[+A] extends Product with Serializable { + def map[B](f: A => B): Action[B] + } + private final case class Emit[+A](event: A) extends Action[A] { + override def map[B](f: A => B): Emit[B] = Emit(f(event)) + } + private case object Drop extends Action[Nothing] { + override def map[B](f: Nothing => B): this.type = this + } private final case class MonotonicityFailure( expectedSequencerCounter: SequencerCounter, timestampLowerBound: CantonTimestamp, event: OrdinarySerializedEvent, - ) extends Action { + ) extends Action[Nothing] { def message: String = s"Sequencer counters and timestamps do not increase monotonically. Expected next counter=$expectedSequencerCounter with timestamp lower bound $timestampLowerBound, but received ${event.signedEvent.content}" def asException: Exception = new MonotonicityFailureException(message) + + override def map[B](f: Nothing => B): this.type = this } @VisibleForTesting class MonotonicityFailureException(message: String) extends Exception(message) diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/SequencerAggregatorPekko.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/SequencerAggregatorPekko.scala index b3afeb485c..1e784a2808 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/SequencerAggregatorPekko.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/SequencerAggregatorPekko.scala @@ -182,10 +182,16 @@ class SequencerAggregatorPekko( override def offsetOfBucket(bucket: Bucket): SequencerCounter = bucket.sequencerCounter - /** The initial offset to start from */ - override def exclusiveLowerBoundForBegin: SequencerCounter = initialCounterOrPriorEvent match { - case Left(initial) => initial - 1L - case Right(priorEvent) => priorEvent.counter + /** The predecessor of the counter to start from */ + override def exclusiveLowerBoundForBegin: SequencerCounter = { + val counterToSubscribeFrom = initialCounterOrPriorEvent match { + case Left(initial) => initial + case Right(priorEvent) => + // The client requests the prior event again to check against ledger forks + priorEvent.counter + } + // Subtract 1 to make it exclusive + counterToSubscribeFrom - 1L } override def traceContextOf(event: OrdinarySerializedEvent): TraceContext = @@ -208,7 +214,7 @@ class SequencerAggregatorPekko( ): Source[OrdinarySerializedEvent, (KillSwitch, Future[Done], HealthComponent)] = { val prior = priorElement.collect { case event @ OrdinarySequencedEvent(_, _) => event } val subscription = eventValidator - .validatePekko(config.subscriptionFactory.create(exclusiveStart), prior, sequencerId) + .validatePekko(config.subscriptionFactory.create(exclusiveStart + 1L), prior, sequencerId) val source = subscription.source .buffer(bufferSize.value, OverflowStrategy.backpressure) .mapConcat(_.unwrap match { @@ -286,7 +292,7 @@ object SequencerAggregatorPekko { def updateHealth(control: SubscriptionControlInternal[?]): Unit = { control match { - case NewConfiguration(newConfig, startingOffset) => + case NewConfiguration(newConfig, _startingOffset) => val currentlyRegisteredDependencies = getDependencies val toRemove = currentlyRegisteredDependencies.keySet diff newConfig.sources.keySet val toAdd = newConfig.sources.collect { case (id, (_config, Some(health))) => diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/SequencerConnection.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/SequencerConnection.scala index ab648d05b3..0f07ae4044 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/SequencerConnection.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/SequencerConnection.scala @@ -6,7 +6,7 @@ package com.digitalasset.canton.sequencing import cats.syntax.either.* import cats.syntax.traverse.* import com.daml.nonempty.NonEmpty -import com.digitalasset.canton.domain.api.v0 +import com.digitalasset.canton.admin.domain.v0 import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} import com.digitalasset.canton.networking.Endpoint import com.digitalasset.canton.networking.grpc.ClientChannelBuilder @@ -156,7 +156,6 @@ object GrpcSequencerConnection { } object SequencerConnection { - def fromProtoV0( configP: v0.SequencerConnection ): ParsingResult[SequencerConnection] = diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/SequencerConnections.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/SequencerConnections.scala index 541749aa2f..c01236306a 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/SequencerConnections.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/SequencerConnections.scala @@ -5,8 +5,8 @@ package com.digitalasset.canton.sequencing import cats.syntax.either.* import com.daml.nonempty.{NonEmpty, NonEmptyUtil} +import com.digitalasset.canton.admin.domain.{v0, v1} import com.digitalasset.canton.config.RequireTypes.PositiveInt -import com.digitalasset.canton.domain.api.{v0, v1} import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} import com.digitalasset.canton.serialization.ProtoConverter import com.digitalasset.canton.serialization.ProtoConverter.{ParsingResult, parseRequiredNonEmpty} diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/PeriodicAcknowledgements.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/PeriodicAcknowledgements.scala index 24e365b380..f1c8dc9cc4 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/PeriodicAcknowledgements.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/PeriodicAcknowledgements.scala @@ -63,6 +63,8 @@ class PeriodicAcknowledgements( logger.debug("Acknowledging sequencer timestamp skipped due to shutdown") ) addToFlushAndLogError("periodic acknowledgement")(updateF) + } else { + logger.debug("Skipping periodic acknowledgement because sequencer client is not healthy") } } @@ -101,10 +103,7 @@ object PeriodicAcknowledgements { Traced.lift((ts, tc) => client .acknowledgeSigned(ts)(tc) - .foldF( - e => if (client.isClosing) Future.unit else Future.failed(new RuntimeException(e)), - _ => Future.unit, - ) + .valueOr(e => if (!client.isClosing) throw new RuntimeException(e)) ), clock, timeouts, diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/ResilientSequencerSubscriberPekko.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/ResilientSequencerSubscriberPekko.scala index ba62c47e2d..37673ef828 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/ResilientSequencerSubscriberPekko.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/ResilientSequencerSubscriberPekko.scala @@ -136,10 +136,10 @@ class ResilientSequencerSubscriberPekko[E]( } } Option.when(canRetry) { - val newDelay = retryDelayRule.nextDelay(lastState.delay, hasReceivedEvent) + val currentDelay = lastState.delay val logMessage = - s"Waiting ${LoggerUtil.roundDurationForHumans(newDelay)} before reconnecting" - if (newDelay < retryDelayRule.warnDelayDuration) { + s"Waiting ${LoggerUtil.roundDurationForHumans(currentDelay)} before reconnecting" + if (currentDelay < retryDelayRule.warnDelayDuration) { logger.debug(logMessage) } else if (lastState.health.isFailed) { logger.info(logMessage) @@ -152,7 +152,8 @@ class ResilientSequencerSubscriberPekko[E]( val nextCounter = lastEmittedElement.fold(lastState.startingCounter)( _.fold(_.lastSequencerCounter, _.counter) ) - lastState.delay -> lastState.copy(startingCounter = nextCounter, delay = newDelay) + val newDelay = retryDelayRule.nextDelay(currentDelay, hasReceivedEvent) + currentDelay -> lastState.copy(startingCounter = nextCounter, delay = newDelay) } } } @@ -287,25 +288,25 @@ object SequencerSubscriptionFactoryPekko { * Changes to the underlying gRPC transport are not supported by the [[ResilientSequencerSubscriberPekko]]; * these can be done via the sequencer aggregator. */ - def fromTransport( + def fromTransport[E]( sequencerID: SequencerId, - transport: SequencerClientTransportPekko, + transport: SequencerClientTransportPekko.Aux[E], requiresAuthentication: Boolean, member: Member, protocolVersion: ProtocolVersion, - ): SequencerSubscriptionFactoryPekko[transport.SubscriptionError] = - new SequencerSubscriptionFactoryPekko[transport.SubscriptionError] { + ): SequencerSubscriptionFactoryPekko[E] = + new SequencerSubscriptionFactoryPekko[E] { override def sequencerId: SequencerId = sequencerID override def create(startingCounter: SequencerCounter)(implicit traceContext: TraceContext - ): SequencerSubscriptionPekko[transport.SubscriptionError] = { + ): SequencerSubscriptionPekko[E] = { val request = SubscriptionRequest(member, startingCounter, protocolVersion) if (requiresAuthentication) transport.subscribe(request) else transport.subscribeUnauthenticated(request) } - override val retryPolicy: SubscriptionErrorRetryPolicyPekko[transport.SubscriptionError] = + override val retryPolicy: SubscriptionErrorRetryPolicyPekko[E] = transport.subscriptionRetryPolicyPekko } } diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClient.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClient.scala index 2581558eb7..e19f8f80ca 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClient.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClient.scala @@ -5,10 +5,14 @@ package com.digitalasset.canton.sequencing.client import cats.data.EitherT import cats.implicits.catsSyntaxOptionId +import cats.syntax.alternative.* import cats.syntax.either.* +import cats.syntax.functor.* +import cats.syntax.parallel.* import com.daml.metrics.Timed import com.daml.nameof.NameOf.functionFullName import com.daml.nonempty.NonEmpty +import com.daml.nonempty.catsinstances.* import com.digitalasset.canton.concurrent.FutureSupervisor import com.digitalasset.canton.config.RequireTypes.PositiveInt import com.digitalasset.canton.config.* @@ -18,10 +22,12 @@ import com.digitalasset.canton.health.{ CloseableHealthComponent, ComponentHealthState, DelegatingMutableHealthComponent, + HealthComponent, } import com.digitalasset.canton.lifecycle.Lifecycle.toCloseableOption +import com.digitalasset.canton.lifecycle.UnlessShutdown.{AbortedDueToShutdown, Outcome} import com.digitalasset.canton.lifecycle.* -import com.digitalasset.canton.logging.pretty.CantonPrettyPrinter +import com.digitalasset.canton.logging.pretty.{CantonPrettyPrinter, Pretty, PrettyPrinting} import com.digitalasset.canton.logging.{ErrorLoggingContext, NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.metrics.SequencerClientMetrics import com.digitalasset.canton.protocol.DomainParameters.MaxRequestSize @@ -30,7 +36,12 @@ import com.digitalasset.canton.protocol.DynamicDomainParametersLookup import com.digitalasset.canton.protocol.messages.DefaultOpenEnvelope import com.digitalasset.canton.resource.DbStorage.PassiveInstanceException import com.digitalasset.canton.sequencing.SequencerAggregator.MessageAggregationConfig +import com.digitalasset.canton.sequencing.SequencerAggregatorPekko.{ + HasSequencerSubscriptionFactoryPekko, + SubscriptionControl, +} import com.digitalasset.canton.sequencing.* +import com.digitalasset.canton.sequencing.client.PeriodicAcknowledgements.FetchCleanTimestamp import com.digitalasset.canton.sequencing.client.SendCallback.CallbackFuture import com.digitalasset.canton.sequencing.client.SequencerClient.SequencerTransports import com.digitalasset.canton.sequencing.client.SequencerClientSubscriptionError.* @@ -49,8 +60,11 @@ import com.digitalasset.canton.store.SequencedEventStore.PossiblyIgnoredSequence import com.digitalasset.canton.store.* import com.digitalasset.canton.time.{Clock, DomainTimeTracker} import com.digitalasset.canton.topology.* -import com.digitalasset.canton.tracing.{Spanning, TraceContext, Traced} +import com.digitalasset.canton.tracing.{HasTraceContext, Spanning, TraceContext, Traced} +import com.digitalasset.canton.util.FutureInstances.* import com.digitalasset.canton.util.FutureUtil.defaultStackTraceFilter +import com.digitalasset.canton.util.PekkoUtil.syntax.* +import com.digitalasset.canton.util.PekkoUtil.{CombinedKillSwitch, WithKillSwitch} import com.digitalasset.canton.util.ShowUtil.* import com.digitalasset.canton.util.* import com.digitalasset.canton.util.retry.RetryUtil.AllExnRetryable @@ -58,6 +72,9 @@ import com.digitalasset.canton.version.ProtocolVersion import com.digitalasset.canton.{DiscardOps, SequencerAlias, SequencerCounter} import com.google.common.annotations.VisibleForTesting import io.opentelemetry.api.trace.Tracer +import org.apache.pekko.stream.scaladsl.{Flow, Keep, Sink, Source} +import org.apache.pekko.stream.{KillSwitch, KillSwitches, Materializer} +import org.apache.pekko.{Done, NotUsed} import org.slf4j.event.Level import java.nio.file.Path @@ -149,24 +166,6 @@ trait SequencerClient extends SequencerClientSend with FlagCloseable { timeTracker: DomainTimeTracker, )(implicit traceContext: TraceContext): Future[Unit] - /** Future which is completed when the client is not functional any more and is ready to be closed. - * The value with which the future is completed will indicate the reason for completion. - */ - def completion: Future[SequencerClient.CloseReason] - - def changeTransport( - sequencerTransports: SequencerTransports[?] - )(implicit traceContext: TraceContext): Future[Unit] - - /** Returns a future that completes after asynchronous processing has completed for all events - * whose synchronous processing has been completed prior to this call. May complete earlier if event processing - * has failed. - */ - @VisibleForTesting - def flush(): Future[Unit] - - def healthComponent: CloseableHealthComponent - /** Acknowledge that we have successfully processed all events up to and including the given timestamp. * The client should then never subscribe for events from before this point. */ @@ -182,11 +181,28 @@ trait SequencerClient extends SequencerClientSend with FlagCloseable { protected def initialCounterLowerBound: SequencerCounter } -/** The sequencer client facilitates access to the individual domain sequencer. A client centralizes the - * message signing operations, as well as the handling and storage of message receipts and delivery proofs, - * such that this functionality does not have to be duplicated throughout the participant node. - */ -class SequencerClientImpl( +trait RichSequencerClient extends SequencerClient { + + def healthComponent: CloseableHealthComponent + + def changeTransport( + sequencerTransports: SequencerTransports[?] + )(implicit traceContext: TraceContext): Future[Unit] + + /** Future which is completed when the client is not functional any more and is ready to be closed. + * The value with which the future is completed will indicate the reason for completion. + */ + def completion: Future[SequencerClient.CloseReason] + + /** Returns a future that completes after asynchronous processing has completed for all events + * whose synchronous processing has been completed prior to this call. May complete earlier if event processing + * has failed. + */ + @VisibleForTesting + def flush(): Future[Unit] +} + +abstract class SequencerClientImpl( val domainId: DomainId, val member: Member, sequencerTransports: SequencerTransports[?], @@ -198,7 +214,7 @@ class SequencerClientImpl( eventValidatorFactory: SequencedEventValidatorFactory, clock: Clock, val requestSigner: RequestSigner, - private val sequencedEventStore: SequencedEventStore, + protected val sequencedEventStore: SequencedEventStore, sendTracker: SendTracker, metrics: SequencerClientMetrics, recorderO: Option[SequencerClientRecorder], @@ -212,64 +228,16 @@ class SequencerClientImpl( extends SequencerClient with FlagCloseableAsync with NamedLogging - with HasFlushFuture with Spanning with HasCloseContext { - private val sequencerAggregator = - new SequencerAggregator( - cryptoPureApi, - config.eventInboxSize, - loggerFactory, - MessageAggregationConfig( - sequencerTransports.expectedSequencers, - sequencerTransports.sequencerTrustThreshold, - ), - timeouts, - futureSupervisor, - ) - - private val sequencersTransportState = + protected val sequencersTransportState = new SequencersTransportState( sequencerTransports, timeouts, loggerFactory, ) - sequencersTransportState.completion.onComplete { _ => - logger.debug( - "The sequencer subscriptions have been closed. Closing sequencer client." - )(TraceContext.empty) - close() - } - - private lazy val deferredSubscriptionHealth = - new DelegatingMutableHealthComponent[SequencerId]( - loggerFactory, - SequencerClient.healthName, - timeouts, - states => - SequencerAggregator - .aggregateHealthResult(states, sequencersTransportState.getSequencerTrustThreshold), - ComponentHealthState.failed("Disconnected from domain"), - ) - - val healthComponent: CloseableHealthComponent = deferredSubscriptionHealth - - private val periodicAcknowledgementsRef = - new AtomicReference[Option[PeriodicAcknowledgements]](None) - - /** Stash for storing the failure that comes out of an application handler, either synchronously or asynchronously. - * If non-empty, no further events should be sent to the application handler. - */ - private val applicationHandlerFailure: SingleUseCell[ApplicationHandlerFailure] = - new SingleUseCell[ApplicationHandlerFailure] - - /** Completed iff the handler is idle. */ - private val handlerIdle: AtomicReference[Promise[Unit]] = new AtomicReference( - Promise.successful(()) - ) - private lazy val printer = new CantonPrettyPrinter(loggingConfig.api.maxStringLength, loggingConfig.api.maxMessageLines) @@ -448,6 +416,7 @@ class SequencerClientImpl( s"failed to retrieve maxRequestSize because ${throwable.getMessage}" ), ) + def trackSend: EitherT[Future, SendAsyncClientError, Unit] = sendTracker .track(messageId, maxSequencingTime, callback) @@ -623,7 +592,140 @@ class SequencerClientImpl( requiresAuthentication = false, ) - private def subscribeAfterInternal( + protected def subscribeAfterInternal( + priorTimestamp: CantonTimestamp, + cleanPreheadTsO: Option[CantonTimestamp], + nonThrottledEventHandler: PossiblyIgnoredApplicationHandler[ClosedEnvelope], + timeTracker: DomainTimeTracker, + fetchCleanTimestamp: PeriodicAcknowledgements.FetchCleanTimestamp, + requiresAuthentication: Boolean, + )(implicit traceContext: TraceContext): Future[Unit] + + /** Acknowledge that we have successfully processed all events up to and including the given timestamp. + * The client should then never subscribe for events from before this point. + */ + private[client] def acknowledge(timestamp: CantonTimestamp)(implicit + traceContext: TraceContext + ): Future[Unit] = { + val request = AcknowledgeRequest(member, timestamp, protocolVersion) + sequencersTransportState.transport.acknowledge(request) + } + + def acknowledgeSigned(timestamp: CantonTimestamp)(implicit + traceContext: TraceContext + ): EitherT[Future, String, Unit] = { + val request = AcknowledgeRequest(member, timestamp, protocolVersion) + for { + signedRequest <- requestSigner.signRequest(request, HashPurpose.AcknowledgementSignature) + _ <- sequencersTransportState.transport.acknowledgeSigned(signedRequest) + } yield () + } + + protected val periodicAcknowledgementsRef = + new AtomicReference[Option[PeriodicAcknowledgements]](None) +} + +/** The sequencer client facilitates access to the individual domain sequencer. A client centralizes the + * message signing operations, as well as the handling and storage of message receipts and delivery proofs, + * such that this functionality does not have to be duplicated throughout the participant node. + */ +class RichSequencerClientImpl( + domainId: DomainId, + member: Member, + sequencerTransports: SequencerTransports[?], + config: SequencerClientConfig, + testingConfig: TestingConfigInternal, + protocolVersion: ProtocolVersion, + domainParametersLookup: DynamicDomainParametersLookup[SequencerDomainParameters], + timeouts: ProcessingTimeout, + eventValidatorFactory: SequencedEventValidatorFactory, + clock: Clock, + requestSigner: RequestSigner, + sequencedEventStore: SequencedEventStore, + sendTracker: SendTracker, + metrics: SequencerClientMetrics, + recorderO: Option[SequencerClientRecorder], + replayEnabled: Boolean, + cryptoPureApi: CryptoPureApi, + loggingConfig: LoggingConfig, + loggerFactory: NamedLoggerFactory, + futureSupervisor: FutureSupervisor, + initialCounterLowerBound: SequencerCounter, +)(implicit executionContext: ExecutionContext, tracer: Tracer) + extends SequencerClientImpl( + domainId, + member, + sequencerTransports, + config, + testingConfig, + protocolVersion, + domainParametersLookup, + timeouts, + eventValidatorFactory, + clock, + requestSigner, + sequencedEventStore, + sendTracker, + metrics, + recorderO, + replayEnabled, + cryptoPureApi, + loggingConfig, + loggerFactory, + futureSupervisor, + initialCounterLowerBound, + ) + with RichSequencerClient + with FlagCloseableAsync + with HasFlushFuture + with Spanning + with HasCloseContext { + + private val sequencerAggregator = + new SequencerAggregator( + cryptoPureApi, + config.eventInboxSize, + loggerFactory, + MessageAggregationConfig( + sequencerTransports.expectedSequencers, + sequencerTransports.sequencerTrustThreshold, + ), + timeouts, + futureSupervisor, + ) + + sequencersTransportState.completion.onComplete { _ => + logger.debug( + "The sequencer subscriptions have been closed. Closing sequencer client." + )(TraceContext.empty) + close() + } + + private lazy val deferredSubscriptionHealth = + new DelegatingMutableHealthComponent[SequencerId]( + loggerFactory, + SequencerClient.healthName, + timeouts, + states => + SequencerAggregator + .aggregateHealthResult(states, sequencersTransportState.getSequencerTrustThreshold), + ComponentHealthState.failed("Disconnected from domain"), + ) + + val healthComponent: CloseableHealthComponent = deferredSubscriptionHealth + + /** Stash for storing the failure that comes out of an application handler, either synchronously or asynchronously. + * If non-empty, no further events should be sent to the application handler. + */ + private val applicationHandlerFailure: SingleUseCell[ApplicationHandlerFailure] = + new SingleUseCell[ApplicationHandlerFailure] + + /** Completed iff the handler is idle. */ + private val handlerIdle: AtomicReference[Promise[Unit]] = new AtomicReference( + Promise.successful(()) + ) + + override protected def subscribeAfterInternal( priorTimestamp: CantonTimestamp, cleanPreheadTsO: Option[CantonTimestamp], nonThrottledEventHandler: PossiblyIgnoredApplicationHandler[ClosedEnvelope], @@ -723,21 +825,20 @@ class SequencerClientImpl( // We only need to it setup once; the sequencer client will direct the acknowledgements to the // right transport. if (requiresAuthentication) { // unauthenticated members don't need to ack - periodicAcknowledgementsRef.compareAndSet( - None, + periodicAcknowledgementsRef.set( PeriodicAcknowledgements .create( config.acknowledgementInterval.underlying, deferredSubscriptionHealth.getState.isOk, - SequencerClientImpl.this, + RichSequencerClientImpl.this, fetchCleanTimestamp, clock, timeouts, loggerFactory, ) - .some, + .some ) - } else None + } } } @@ -1055,7 +1156,7 @@ class SequencerClientImpl( .fromFuture(asyncResultF, handleException(_, syncProcessing = true)) .subflatMap { case UnlessShutdown.Outcome(asyncResult) => - val asyncSignalledF = asyncResult.unwrap.transform { result => + val asyncSignalledF = asyncResult.unwrap.transformIntoSuccess { result => // record errors and shutdown in `applicationHandlerFailure` and move on result match { case Success(outcome) => @@ -1067,7 +1168,7 @@ class SequencerClientImpl( case Failure(error) => handleException(error, syncProcessing = false).discard } - Success(UnlessShutdown.unit) + UnlessShutdown.unit }.unwrap // note, we are adding our async processing to the flush future, so we know once the async processing has finished addToFlushAndLogError( @@ -1088,26 +1189,6 @@ class SequencerClientImpl( }(EitherT.leftT[Future, Unit](_)) } - /** Acknowledge that we have successfully processed all events up to and including the given timestamp. - * The client should then never subscribe for events from before this point. - */ - private[client] def acknowledge(timestamp: CantonTimestamp)(implicit - traceContext: TraceContext - ): Future[Unit] = { - val request = AcknowledgeRequest(member, timestamp, protocolVersion) - sequencersTransportState.transport.acknowledge(request) - } - - def acknowledgeSigned(timestamp: CantonTimestamp)(implicit - traceContext: TraceContext - ): EitherT[Future, String, Unit] = { - val request = AcknowledgeRequest(member, timestamp, protocolVersion) - for { - signedRequest <- requestSigner.signRequest(request, HashPurpose.AcknowledgementSignature) - _ <- sequencersTransportState.transport.acknowledgeSigned(signedRequest) - } yield () - } - def changeTransport( sequencerTransports: SequencerTransports[?] )(implicit traceContext: TraceContext): Future[Unit] = { @@ -1203,6 +1284,405 @@ class SequencerClientImpl( } } +class SequencerClientImplPekko[E: Pretty]( + domainId: DomainId, + member: Member, + sequencerTransports: SequencerTransports[E], + config: SequencerClientConfig, + testingConfig: TestingConfigInternal, + protocolVersion: ProtocolVersion, + domainParametersLookup: DynamicDomainParametersLookup[SequencerDomainParameters], + timeouts: ProcessingTimeout, + eventValidatorFactory: SequencedEventValidatorFactory, + clock: Clock, + requestSigner: RequestSigner, + sequencedEventStore: SequencedEventStore, + sendTracker: SendTracker, + metrics: SequencerClientMetrics, + recorderO: Option[SequencerClientRecorder], + replayEnabled: Boolean, + cryptoPureApi: CryptoPureApi, + loggingConfig: LoggingConfig, + loggerFactory: NamedLoggerFactory, + futureSupervisor: FutureSupervisor, + initialCounterLowerBound: SequencerCounter, +)(implicit executionContext: ExecutionContext, tracer: Tracer, materializer: Materializer) + extends SequencerClientImpl( + domainId, + member, + sequencerTransports, + config, + testingConfig, + protocolVersion, + domainParametersLookup, + timeouts, + eventValidatorFactory, + clock, + requestSigner, + sequencedEventStore, + sendTracker, + metrics, + recorderO, + replayEnabled, + cryptoPureApi, + loggingConfig, + loggerFactory, + futureSupervisor, + initialCounterLowerBound, + ) { + + import SequencerClientImplPekko.* + + private val subscriptionHandle: AtomicReference[Option[SubscriptionHandle]] = + new AtomicReference[Option[SubscriptionHandle]](None) + + override protected def subscribeAfterInternal( + priorTimestamp: CantonTimestamp, + cleanPreheadTsO: Option[CantonTimestamp], + nonThrottledEventHandler: PossiblyIgnoredApplicationHandler[ClosedEnvelope], + timeTracker: DomainTimeTracker, + fetchCleanTimestamp: FetchCleanTimestamp, + requiresAuthentication: Boolean, + )(implicit traceContext: TraceContext): Future[Unit] = { + val throttledEventHandler = ThrottlingApplicationEventHandler.throttle( + config.maximumInFlightEventBatches, + nonThrottledEventHandler, + metrics, + ) + val subscriptionF = performUnlessClosingUSF(functionFullName) { + for { + initialPriorEventO <- FutureUnlessShutdown.outcomeF( + sequencedEventStore + .find(SequencedEventStore.LatestUpto(priorTimestamp)) + .toOption + .value + ) + _ = if (initialPriorEventO.isEmpty) { + logger.info(s"No event found up to $priorTimestamp. Resubscribing from the beginning.") + } + _ = cleanPreheadTsO.zip(initialPriorEventO).fold(()) { + case (cleanPreheadTs, initialPriorEvent) => + ErrorUtil.requireArgument( + initialPriorEvent.timestamp <= cleanPreheadTs, + s"The initial prior event's timestamp ${initialPriorEvent.timestamp} is after the clean prehead at $cleanPreheadTs.", + ) + } + + // bulk-feed the event handler with everything that we already have in the SequencedEventStore + replayStartTimeInclusive = initialPriorEventO + .fold(CantonTimestamp.MinValue)(_.timestamp) + .immediateSuccessor + _ = logger.info( + s"Processing events from the SequencedEventStore from ${replayStartTimeInclusive} on" + ) + + replayEvents <- FutureUnlessShutdown.outcomeF( + sequencedEventStore + .findRange( + SequencedEventStore + .ByTimestampRange(replayStartTimeInclusive, CantonTimestamp.MaxValue), + limit = None, + ) + .valueOr { overlap => + ErrorUtil.internalError( + new IllegalStateException( + s"Sequenced event store's pruning at ${overlap.pruningStatus.timestamp} is at or after the resubscription at $replayStartTimeInclusive." + ) + ) + } + ) + subscriptionStartsAt = replayEvents.headOption.fold( + cleanPreheadTsO.fold(SubscriptionStart.FreshSubscription: SubscriptionStart)( + SubscriptionStart.CleanHeadResubscriptionStart + ) + )(replayEv => + SubscriptionStart.ReplayResubscriptionStart(replayEv.timestamp, cleanPreheadTsO) + ) + _ = replayEvents.lastOption + .orElse(initialPriorEventO) + .foreach(event => timeTracker.subscriptionResumesAfter(event.timestamp)) + _ <- throttledEventHandler.subscriptionStartsAt(subscriptionStartsAt, timeTracker) + } yield { + val preSubscriptionEvent = replayEvents.lastOption.orElse(initialPriorEventO) + // previously seen counter takes precedence over the lower bound + val firstCounter = preSubscriptionEvent.fold(initialCounterLowerBound)(_.counter) + val initialCounterOrPriorEvent = preSubscriptionEvent.toRight(firstCounter) + lazy val subscriptionStartLogMessage = initialCounterOrPriorEvent match { + case Left(counter) => + s"Subscription starts without prior event at counter $counter" + case Right(event) => + s"Subscription starts at prior event at ${event.timestamp} with counter ${event.counter}" + } + logger.debug(subscriptionStartLogMessage) + + val eventValidator = eventValidatorFactory.create(unauthenticated = !requiresAuthentication) + val aggregator = new SequencerAggregatorPekko( + domainId, + eventValidator, + bufferSize = PositiveInt.one, + cryptoPureApi, + loggerFactory, + // TODO(#13789) wire this up + enableInvariantCheck = false, + ) + + val replayCompleted = mkPromise[Unit]("replay-of-sequenced-events", futureSupervisor) + + val batchedReplayedEvents = replayEvents + .grouped(config.eventInboxSize.unwrap) + .map { batch => + val batchTraceContext = TraceContext.ofBatch(batch)(logger) + WithPromise(Traced(batch)(batchTraceContext))() + } + .toSeq + // Zip together all the completions of the replayed events + val replayCompletion = batchedReplayedEvents.parTraverse_ { withPromise => + withPromise.promise.future + } + replayCompleted.completeWith(FutureUnlessShutdown.outcomeF(replayCompletion)) + + val replayedEventsSource = + Source(batchedReplayedEvents).map(Right(_)).viaMat(KillSwitches.single)(Keep.right) + + val sequencerConnectionConfig = + OrderedBucketMergeConfig[SequencerId, HasSequencerSubscriptionFactoryPekko[E]]( + sequencerTransports.sequencerTrustThreshold, + sequencerTransports.sequencerIdToTransportMap.toNEF.fmap { transportContainer => + SequencerSubscriptionFactoryPekko.fromTransport( + transportContainer.sequencerId, + transportContainer.clientTransport, + requiresAuthentication, + member, + protocolVersion, + ) + }.fromNEF, + ) + + val configSource = Source + .single(sequencerConnectionConfig) + .concat(Source.never) + .viaMat(KillSwitches.single)(Keep.right) + + val monotonicityChecker = new SequencedEventMonotonicityChecker( + firstCounter, + preSubscriptionEvent.fold(CantonTimestamp.MinValue)(_.timestamp), + loggerFactory, + ) + val storeSequencedEvent = StoreSequencedEvent(sequencedEventStore, domainId, loggerFactory) + + val aggregatorFlow = aggregator.aggregateFlow(initialCounterOrPriorEvent) + val subscriptionSource = configSource + .viaMat(aggregatorFlow)(Keep.both) + .injectKillSwitch { case (killSwitch, _) => killSwitch } + .via(monotonicityChecker.flow) + .map(_.unwrap) + // Drop the first event if it's a resubscription because we don't want to pass it to the application handler any more + .via(dropPriorEvent(preSubscriptionEvent.isDefined)) + .via(batchFlow) + .mapAsync(parallelism = 1) { controlOrEvent => + controlOrEvent.traverse(tracedEvents => + sendTracker.update(tracedEvents.value).map((_: Unit) => tracedEvents) + ) + } + .map(_.map(eventBatch => WithPromise(eventBatch)())) + + type F1[+A] = Either[SubscriptionControl[E], WithPromise[A]] + implicit val singletonTraverseT: SingletonTraverse.Aux[F1, Promise[Unit]] = + SingletonTraverse[Either[SubscriptionControl[E], *]] + .composeWith(SingletonTraverse[WithPromise])(Keep.right) + val persistedSubscriptionSource = subscriptionSource + .via(storeSequencedEvent.flow[F1]) + .via(timeTracker.flow[F1, ClosedEnvelope]) + + val eventSource: Source[ + Either[SubscriptionControl[E], WithPromise[Traced[ + Seq[PossiblyIgnoredSerializedEvent] + ]]], + (KillSwitch, Future[Done], HealthComponent), + ] = replayedEventsSource.concatLazyMat(persistedSubscriptionSource) { + (replayedKillSwitch, subscriptionMat) => + val (subscriptionKillSwitch, (doneF, health)) = subscriptionMat + val combinedKillSwitch = + new CombinedKillSwitch(replayedKillSwitch, subscriptionKillSwitch) + (combinedKillSwitch, doneF, health) + } + + type F2[+X] = WithKillSwitch[F1[X]] + implicit val singletonTraverseF: SingletonTraverse.Aux[F2, (KillSwitch, Promise[Unit])] = + SingletonTraverse[WithKillSwitch].composeWith(SingletonTraverse[F1])(Keep.both) + + val applicationHandlerPekko = new ApplicationHandlerPekko[F2, (KillSwitch, Promise[Unit])]( + throttledEventHandler, + metrics, + loggerFactory, + { case (killSwitch, _) => killSwitch }, + ) + + val stream = eventSource + .injectKillSwitch { case (killSwitch, _, _) => killSwitch } + .via(applicationHandlerPekko.asFlow(config.maximumInFlightEventBatches)) + // Mark that the application handler has finished a given batch of promises. + .map(_.map(_.map { withPromise => + val completion = withPromise.unwrap match { + case Outcome(Left(error)) => Failure(SequencerClientSubscriptionException(error)) + case _ => Success(()) + } + withPromise.promise.tryComplete(completion).discard[Boolean] + withPromise + })) + .collect { + // Discard AbortedDueToShutdown and normal asynchronous results: + // AbortedDueToShutdown may originate from asynchronous processing, + // but the root cause of the shutdown could be a failure in the synchronous processing, + // possibly for a later event. + // So a regular shutdown will appear as a normal completion of the stream. + // + // Also discard control messages (Left). + // TODO(#13789) This may change when we support dynamic transport changes + case WithKillSwitch(Right(WithPromise(Outcome(Left(error))))) => + error + } + .toMat(Sink.lastOption) { (matEventSource, lastF) => + val extractedFailureF = lastF.map { + case None => + logger.debug("sequencer subscription stream terminated normally") + AbortedDueToShutdown + case Some(error) => + logger.debug(s"sequencer subscription stream terminated abnormally: $error") + Outcome(error) + } + matEventSource -> FutureUnlessShutdown(extractedFailureF) + } + + val ((killSwitch, subscriptionDoneF, health), completion) = + PekkoUtil.runSupervised(logger.error("Sequencer subscription failed", _), stream) + val handle = SubscriptionHandle(killSwitch, subscriptionDoneF, completion) + subscriptionHandle.getAndSet(Some(handle)).foreach { _ => + // TODO(#13789) Clean up the error logging. + // Currently, this mimics com.digitalasset.canton.sequencing.client.SequencersTransportState.addSubscription + logger.warn( + "Cannot create additional subscriptions to the sequencer from the same client" + ) + throw new IllegalArgumentException( + s"The sequencer client already has a running subscription" + ) + } + + // periodically acknowledge that we've successfully processed up to the clean counter + // We only need to it setup once; the sequencer client will direct the acknowledgements to the + // right transport. + if (requiresAuthentication) { // unauthenticated members don't need to ack + periodicAcknowledgementsRef.set( + PeriodicAcknowledgements + .create( + config.acknowledgementInterval.underlying, + health.getState.isOk, + this, + fetchCleanTimestamp, + clock, + timeouts, + loggerFactory, + ) + .some + ) + } + + replayCompleted.futureUS + } + } + // we may have actually not created a subscription if we have been closed + val loggedAbortF = subscriptionF.flatten.onShutdown { + logger.info("Ignoring the sequencer subscription request as the client is being closed") + } + + FutureUtil.logOnFailure(loggedAbortF, "Sequencer subscription failed") + } + + private def dropPriorEvent[A, B](doDrop: Boolean): Flow[Either[A, B], Either[A, B], NotUsed] = + if (doDrop) Flow[Either[A, B]].dropIf(1)(_.isRight) + else Flow[Either[A, B]] + + private def batchFlow[A, B <: HasTraceContext](implicit + traceContext: TraceContext + ): Flow[Either[A, B], Either[A, Traced[Seq[B]]], NotUsed] = + Flow[Either[A, B]] + .batchN(config.eventInboxSize.unwrap, 1) + .mapConcat { batchBuffer => + val batchesOrError = + IterableUtil.spansBy(batchBuffer.toSeq)(_.isRight).flatMap { case (_, block) => + val (lefts, rights) = block.forgetNE.separate + ErrorUtil.requireState( + lefts.isEmpty || rights.isEmpty, + "spansBy returned Lefts and Rights in the same block", + ) + if (lefts.isEmpty) { + val batchTraceContext = TraceContext.ofBatch(rights)(logger) + Seq(Right(Traced(rights)(batchTraceContext))) + } else lefts.map(left => Left(left)) + } + batchesOrError + } + + override protected def closeAsync(): Seq[AsyncOrSyncCloseable] = { + subscriptionHandle.get.toList.flatMap { handle => + import com.digitalasset.canton.tracing.TraceContext.Implicits.Empty.* + + Seq( + SyncCloseable("subscription kill switch", handle.killSwitch.shutdown()), + AsyncCloseable( + "subscription completion", + handle.sourceCompletion, + timeouts.shutdownProcessing, + ), + AsyncCloseable( + "application handler completion", + handle.applicationHandlerCompletion.unwrap, + timeouts.shutdownProcessing, + ), + ) + } + } +} + +object SequencerClientImplPekko { + private final case class WithPromise[+A](private val value: A)( + val promise: Promise[Unit] = Promise[Unit]() + ) extends WithGeneric[A, Promise[Unit], WithPromise] { + override def unwrap: A = value + override protected def added: Promise[Unit] = promise + override protected def update[AA](value: AA): WithPromise[AA] = copy(value)(promise) + } + private object WithPromise extends WithGenericCompanion { + implicit val singletonTraverseWithPromise: SingletonTraverse.Aux[WithPromise, Promise[Unit]] = + singletonTraverseWithGeneric[Promise[Unit], WithPromise] + } + + private final case class SubscriptionHandle( + killSwitch: KillSwitch, + sourceCompletion: Future[Done], + applicationHandlerCompletion: FutureUnlessShutdown[ApplicationHandlerError], + ) + + private sealed trait BatchCounterRange extends Product with Serializable with PrettyPrinting + private case object EmptyBatch extends BatchCounterRange { + override def pretty: Pretty[EmptyBatch.type] = prettyOfObject[EmptyBatch.type] + } + private final case class NonEmptyBatchCounterRange(start: SequencerCounter, end: SequencerCounter) + extends BatchCounterRange { + override def pretty: Pretty[NonEmptyBatchCounterRange] = prettyInfix(_.start, "->", _.end) + } + private object BatchCounterRange { + def apply( + batch: Traced[Seq[PossiblyIgnoredSerializedEvent]] + ): BatchCounterRange = + NonEmpty.from(batch.value) match { + case None => EmptyBatch + case Some(batchNE) => + NonEmptyBatchCounterRange(batchNE.head1.counter, batchNE.last1.counter) + } + } +} + object SequencerClient { val healthName: String = "sequencer-client" diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClientFactory.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClientFactory.scala index ef777a6572..2fdf98fc34 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClientFactory.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClientFactory.scala @@ -59,7 +59,7 @@ trait SequencerClientFactory { materializer: Materializer, tracer: Tracer, traceContext: TraceContext, - ): EitherT[Future, String, SequencerClient] + ): EitherT[Future, String, RichSequencerClient] } @@ -100,7 +100,7 @@ object SequencerClientFactory { materializer: Materializer, tracer: Tracer, traceContext: TraceContext, - ): EitherT[Future, String, SequencerClient] = { + ): EitherT[Future, String, RichSequencerClient] = { // initialize recorder if it's been configured for the member (should only be used for testing) val recorderO = recordingConfigForMember(member).map { recordingConfig => new SequencerClientRecorder( @@ -161,7 +161,7 @@ object SequencerClientFactory { ) } } - } yield new SequencerClientImpl( + } yield new RichSequencerClientImpl( domainId, member, sequencerTransports, @@ -190,19 +190,21 @@ object SequencerClientFactory { connection: SequencerConnection, member: Member, requestSigner: RequestSigner, + allowReplay: Boolean = true, )(implicit executionContext: ExecutionContextExecutor, executionSequencerFactory: ExecutionSequencerFactory, materializer: Materializer, traceContext: TraceContext, ): EitherT[Future, String, SequencerClientTransport & SequencerClientTransportPekko] = { + // TODO(#13789) Use only `SequencerClientTransportPekko` as the return type def mkRealTransport(): SequencerClientTransport & SequencerClientTransportPekko = connection match { case grpc: GrpcSequencerConnection => grpcTransport(grpc, member) } val transport: SequencerClientTransport & SequencerClientTransportPekko = - replayConfigForMember(member) match { + replayConfigForMember(member).filter(_ => allowReplay) match { case None => mkRealTransport() case Some(ReplayConfig(recording, SequencerEvents)) => new ReplayingEventsSequencerClientTransport( diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClientSubscriptionError.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClientSubscriptionError.scala index 7d63776df9..8d1468d8d2 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClientSubscriptionError.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClientSubscriptionError.scala @@ -28,8 +28,10 @@ object SequencerClientSubscriptionError { prettyOfObject[ApplicationHandlerShutdown.type] } + sealed trait ApplicationHandlerError extends ApplicationHandlerFailure + /** The application handler returned that it is being passive. */ - final case class ApplicationHandlerPassive(reason: String) extends ApplicationHandlerFailure { + final case class ApplicationHandlerPassive(reason: String) extends ApplicationHandlerError { override def pretty: Pretty[ApplicationHandlerPassive] = prettyOfClass(param("reason", _.reason.unquoted)) } @@ -39,7 +41,7 @@ object SequencerClientSubscriptionError { exception: Throwable, firstSequencerCounter: SequencerCounter, lastSequencerCounter: SequencerCounter, - ) extends ApplicationHandlerFailure { + ) extends ApplicationHandlerError { override def mbException: Option[Throwable] = Some(exception) override def pretty: Pretty[ApplicationHandlerException] = prettyOfClass( diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClientTransportFactory.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClientTransportFactory.scala index 68e02a769c..1800a908be 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClientTransportFactory.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/SequencerClientTransportFactory.scala @@ -83,6 +83,7 @@ trait SequencerClientTransportFactory { connection: SequencerConnection, member: Member, requestSigner: RequestSigner, + allowReplay: Boolean = true, )(implicit executionContext: ExecutionContextExecutor, executionSequencerFactory: ExecutionSequencerFactory, diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/transports/SequencerClientTransportPekko.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/transports/SequencerClientTransportPekko.scala index 081195cc9d..f98c0311c2 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/transports/SequencerClientTransportPekko.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/client/transports/SequencerClientTransportPekko.scala @@ -28,7 +28,6 @@ trait SequencerClientTransportPekko extends SequencerClientTransportCommon { /** The transport can decide which errors will cause the sequencer client to not try to reestablish a subscription */ def subscriptionRetryPolicyPekko: SubscriptionErrorRetryPolicyPekko[SubscriptionError] - } object SequencerClientTransportPekko { diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/handlers/StoreSequencedEvent.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/handlers/StoreSequencedEvent.scala index c2f5f2af42..7cc4bdfce9 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/handlers/StoreSequencedEvent.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/sequencing/handlers/StoreSequencedEvent.scala @@ -5,14 +5,23 @@ package com.digitalasset.canton.sequencing.handlers import com.digitalasset.canton.lifecycle.{CloseContext, FutureUnlessShutdown} import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} -import com.digitalasset.canton.sequencing.OrdinaryApplicationHandler import com.digitalasset.canton.sequencing.protocol.ClosedEnvelope +import com.digitalasset.canton.sequencing.{ + BoxedEnvelope, + OrdinaryApplicationHandler, + OrdinaryEnvelopeBox, + OrdinarySerializedEvent, +} import com.digitalasset.canton.store.SequencedEventStore import com.digitalasset.canton.topology.DomainId -import com.digitalasset.canton.util.ErrorUtil +import com.digitalasset.canton.tracing.Traced import com.digitalasset.canton.util.ShowUtil.* +import com.digitalasset.canton.util.SingletonTraverse.syntax.* +import com.digitalasset.canton.util.{ErrorUtil, SingletonTraverse} +import org.apache.pekko.NotUsed +import org.apache.pekko.stream.scaladsl.Flow -import scala.concurrent.ExecutionContext +import scala.concurrent.{ExecutionContext, Future} /** Transformer for [[com.digitalasset.canton.sequencing.OrdinaryApplicationHandler]] * that stores all event batches in the [[com.digitalasset.canton.store.SequencedEventStore]] @@ -22,38 +31,52 @@ class StoreSequencedEvent( store: SequencedEventStore, domainId: DomainId, protected override val loggerFactory: NamedLoggerFactory, -)(implicit ec: ExecutionContext) +)(implicit ec: ExecutionContext, closeContext: CloseContext) extends NamedLogging { + def flow[F[_]](implicit F: SingletonTraverse[F]): Flow[ + F[Traced[Seq[OrdinarySerializedEvent]]], + F[Traced[Seq[OrdinarySerializedEvent]]], + NotUsed, + ] = Flow[F[Traced[Seq[OrdinarySerializedEvent]]]] + // Store the events as part of the flow + .mapAsync(parallelism = 1)(_.traverseSingleton { + // TODO(#13789) Properly deal with exceptions + (_, tracedEvents) => storeBatch(tracedEvents).map((_: Unit) => tracedEvents) + }) + def apply( handler: OrdinaryApplicationHandler[ClosedEnvelope] - )(implicit closeContext: CloseContext): OrdinaryApplicationHandler[ClosedEnvelope] = + ): OrdinaryApplicationHandler[ClosedEnvelope] = handler.replace(tracedEvents => - tracedEvents.withTraceContext { implicit batchTraceContext => events => - val wrongDomainEvents = events.filter(_.signedEvent.content.domainId != domainId) - for { - _ <- FutureUnlessShutdown.outcomeF( - ErrorUtil.requireArgumentAsync( - wrongDomainEvents.isEmpty, { - val wrongDomainIds = wrongDomainEvents.map(_.signedEvent.content.domainId).distinct - val wrongDomainCounters = wrongDomainEvents.map(_.signedEvent.content.counter) - show"Cannot store sequenced events from domains $wrongDomainIds in store for domain $domainId\nSequencer counters: $wrongDomainCounters" - }, - ) - ) - // The events must be stored before we call the handler - // so that during crash recovery the `SequencerClient` can use the first event in the - // `SequencedEventStore` as the beginning of the resubscription even if that event is not known to be clean. - _ <- FutureUnlessShutdown.outcomeF(store.store(events)) - result <- handler(tracedEvents) - } yield result - } + FutureUnlessShutdown.outcomeF(storeBatch(tracedEvents)).flatMap(_ => handler(tracedEvents)) ) + + private def storeBatch( + tracedEvents: BoxedEnvelope[OrdinaryEnvelopeBox, ClosedEnvelope] + ): Future[Unit] = { + tracedEvents.withTraceContext { implicit batchTraceContext => events => + val wrongDomainEvents = events.filter(_.signedEvent.content.domainId != domainId) + ErrorUtil.requireArgument( + wrongDomainEvents.isEmpty, { + val wrongDomainIds = wrongDomainEvents.map(_.signedEvent.content.domainId).distinct + val wrongDomainCounters = wrongDomainEvents.map(_.signedEvent.content.counter) + show"Cannot store sequenced events from domains $wrongDomainIds in store for domain $domainId\nSequencer counters: $wrongDomainCounters" + }, + ) + // The events must be stored before we call the handler + // so that during crash recovery the `SequencerClient` can use the first event in the + // `SequencedEventStore` as the beginning of the resubscription even if that event is not known to be clean. + store.store(events) + } + } } object StoreSequencedEvent { def apply(store: SequencedEventStore, domainId: DomainId, loggerFactory: NamedLoggerFactory)( - implicit ec: ExecutionContext + implicit + ec: ExecutionContext, + closeContext: CloseContext, ): StoreSequencedEvent = new StoreSequencedEvent(store, domainId, loggerFactory) } diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/time/DomainTimeTracker.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/time/DomainTimeTracker.scala index c094531c05..02b2f797f0 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/time/DomainTimeTracker.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/time/DomainTimeTracker.scala @@ -3,6 +3,8 @@ package com.digitalasset.canton.time +import cats.Foldable +import cats.syntax.foldable.* import cats.syntax.option.* import com.daml.nameof.NameOf.functionFullName import com.daml.nonempty.NonEmpty @@ -10,9 +12,13 @@ import com.digitalasset.canton.config.{DomainTimeTrackerConfig, ProcessingTimeou import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.lifecycle.{FlagCloseable, FutureUnlessShutdown, UnlessShutdown} import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} -import com.digitalasset.canton.sequencing.OrdinaryApplicationHandler import com.digitalasset.canton.sequencing.client.SequencerClient import com.digitalasset.canton.sequencing.protocol.{Envelope, TimeProof} +import com.digitalasset.canton.sequencing.{ + BoxedEnvelope, + OrdinaryApplicationHandler, + OrdinaryEnvelopeBox, +} import com.digitalasset.canton.store.SequencedEventStore.OrdinarySequencedEvent import com.digitalasset.canton.time.DomainTimeTracker.* import com.digitalasset.canton.tracing.TraceContext @@ -21,6 +27,8 @@ import com.digitalasset.canton.util.Thereafter.syntax.* import com.digitalasset.canton.util.* import com.digitalasset.canton.version.ProtocolVersion import com.google.common.annotations.VisibleForTesting +import org.apache.pekko.NotUsed +import org.apache.pekko.stream.scaladsl.Flow import java.util.concurrent.PriorityBlockingQueue import java.util.concurrent.atomic.AtomicReference @@ -178,6 +186,17 @@ class DomainTimeTracker( } } + def flow[F[_], Env <: Envelope[_]](implicit F: Foldable[F]): Flow[ + F[BoxedEnvelope[OrdinaryEnvelopeBox, Env]], + F[BoxedEnvelope[OrdinaryEnvelopeBox, Env]], + NotUsed, + ] = Flow[F[BoxedEnvelope[OrdinaryEnvelopeBox, Env]]].map { tracedEventsF => + tracedEventsF.toIterable.foreach(_.withTraceContext { implicit batchTraceContext => events => + update(events) + }) + tracedEventsF + } + /** Create a [[sequencing.OrdinaryApplicationHandler]] for updating this time tracker */ def wrapHandler[Env <: Envelope[_]]( handler: OrdinaryApplicationHandler[Env] diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/topology/DomainOutboxQueue.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/topology/DomainOutboxQueue.scala index e91041da77..ea77bd2158 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/topology/DomainOutboxQueue.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/topology/DomainOutboxQueue.scala @@ -40,29 +40,32 @@ class DomainOutboxQueue(val loggerFactory: NamedLoggerFactory) extends NamedLogg * @param limit batch size * @return the topology transactions that have been marked as pending. */ - def dequeue(limit: Int): Seq[GenericSignedTopologyTransactionX] = blocking(synchronized { - logger.debug("dequeuing")(TraceContext.todo) + def dequeue(limit: Int)(implicit + traceContext: TraceContext + ): Seq[GenericSignedTopologyTransactionX] = blocking(synchronized { + val txs = unsentQueue.take(limit) + logger.debug(s"dequeuing: $txs") require( pendingQueue.isEmpty, s"tried to dequeue while pending wasn't empty: ${pendingQueue.toSeq}", ) - pendingQueue.enqueueAll(unsentQueue.take(limit)) + pendingQueue.enqueueAll(txs) unsentQueue.remove(0, limit) pendingQueue.toSeq }) /** Marks the currently pending transactions as unsent and adds them to the front of the queue in the same order. */ - def requeue(): Unit = blocking(synchronized { - logger.debug(s"requeuing $pendingQueue")(TraceContext.todo) + def requeue()(implicit traceContext: TraceContext): Unit = blocking(synchronized { + logger.debug(s"requeuing $pendingQueue") unsentQueue.prependAll(pendingQueue) pendingQueue.clear() }) /** Clears the currently pending transactions. */ - def completeCycle(): Unit = blocking(synchronized { - logger.debug("completeCycle")(TraceContext.todo) + def completeCycle()(implicit traceContext: TraceContext): Unit = blocking(synchronized { + logger.debug(s"completeCycle $pendingQueue") pendingQueue.clear() }) diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/topology/TopologyManagerX.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/topology/TopologyManagerX.scala index 8eaa6f8754..60f096d205 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/topology/TopologyManagerX.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/topology/TopologyManagerX.scala @@ -27,7 +27,6 @@ import com.digitalasset.canton.util.FutureInstances.* import com.digitalasset.canton.util.ShowUtil.* import com.digitalasset.canton.util.{MonadUtil, SimpleExecutionQueue} import com.digitalasset.canton.version.ProtocolVersion -import com.google.common.annotations.VisibleForTesting import java.util.concurrent.atomic.AtomicReference import scala.concurrent.{ExecutionContext, Future} @@ -138,7 +137,6 @@ abstract class TopologyManagerX[+StoreID <: TopologyStoreId]( def removeObserver(observer: TopologyManagerObserver): Unit = observers.updateAndGet(_.filterNot(_ == observer)).discard - @VisibleForTesting def clearObservers(): Unit = observers.set(Seq.empty) /** Authorizes a new topology transaction by signing it and adding it to the topology state @@ -322,7 +320,7 @@ abstract class TopologyManagerX[+StoreID <: TopologyStoreId]( // TODO(#12945) get signing keys for transaction. EitherT.leftT( TopologyManagerError.InternalError.ImplementMe( - "Automatic signing key lookup not yet implemented. Please specify a signing explicitly." + "Automatic signing key lookup not yet implemented. Please specify a signing key explicitly." ) ) }): EitherT[Future, TopologyManagerError, NonEmpty[Set[Fingerprint]]] diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/topology/store/TopologyStateForInititalizationService.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/topology/store/TopologyStateForInititalizationService.scala index f4e47d4471..a3d0b8a604 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/topology/store/TopologyStateForInititalizationService.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/topology/store/TopologyStateForInititalizationService.scala @@ -32,8 +32,6 @@ final class StoreBasedTopologyStateForInitializationService( * 4. Find the maximum effective time of the transactions returned in 3. (here ts1') * 5. Set all validUntil > ts1' to None * - * TODO(#13394) adapt this logic to allow onboarding of a previously offboarded member - * * {{{ * * t0 , t1 ... sequenced time diff --git a/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/parallel/BatchN.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/BatchN.scala similarity index 97% rename from canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/parallel/BatchN.scala rename to canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/BatchN.scala index ff7e01fec6..516d9366c3 100644 --- a/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/parallel/BatchN.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/BatchN.scala @@ -1,7 +1,7 @@ // Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -package com.digitalasset.canton.platform.indexer.parallel +package com.digitalasset.canton.util import org.apache.pekko.NotUsed import org.apache.pekko.stream.scaladsl.Flow diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/EitherTUtil.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/EitherTUtil.scala index 70d1fa5f54..f24c8dfb14 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/EitherTUtil.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/EitherTUtil.scala @@ -36,28 +36,21 @@ object EitherTUtil { } def onErrorOrFailureUnlessShutdown[A, B]( - errorHandler: () => Unit, + errorHandler: Either[Throwable, A] => Unit, shutdownHandler: () => Unit = () => (), )( fn: => EitherT[FutureUnlessShutdown, A, B] )(implicit executionContext: ExecutionContext): EitherT[FutureUnlessShutdown, A, B] = fn.thereafter { - case Failure(_) => - errorHandler() - case Success(UnlessShutdown.Outcome(Left(_))) => - errorHandler() + case Failure(t) => + errorHandler(Left(t)) + case Success(UnlessShutdown.Outcome(Left(left))) => + errorHandler(Right(left)) case Success(UnlessShutdown.AbortedDueToShutdown) => shutdownHandler() case _ => () } - def onErrorOrFailureOrShutdown[A, B]( - errorAndShutdownHandler: () => Unit - )( - fn: => EitherT[FutureUnlessShutdown, A, B] - )(implicit executionContext: ExecutionContext): EitherT[FutureUnlessShutdown, A, B] = - onErrorOrFailureUnlessShutdown(errorAndShutdownHandler, errorAndShutdownHandler)(fn) - /** Lifts an `if (cond) then ... else ()` into the `EitherT` a pplicative */ def ifThenET[F[_], L](cond: Boolean)(`then`: => EitherT[F, L, _])(implicit F: Applicative[F] diff --git a/canton-3x/community/common/src/main/scala/com/digitalasset/canton/util/IterableUtil.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/IterableUtil.scala similarity index 100% rename from canton-3x/community/common/src/main/scala/com/digitalasset/canton/util/IterableUtil.scala rename to canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/IterableUtil.scala diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/OrderedBucketMergeHub.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/OrderedBucketMergeHub.scala index c54a665198..7c991dc664 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/OrderedBucketMergeHub.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/OrderedBucketMergeHub.scala @@ -1019,6 +1019,8 @@ object OrderedBucketMergeHub { /** Signals the new configuration that is active for all subsequent elements until the next [[NewConfiguration]] * and the materialized values for the newly created sources. + * + * @param startingOffset The exclusive offset where the subscription starts */ final case class NewConfiguration[Name, +ConfigAndMat, +Offset]( newConfig: OrderedBucketMergeConfig[Name, ConfigAndMat], diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/PekkoUtil.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/PekkoUtil.scala index ecf37876d7..3d80978a20 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/PekkoUtil.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/PekkoUtil.scala @@ -3,7 +3,7 @@ package com.digitalasset.canton.util -import cats.{Applicative, Eval, Functor, Id} +import cats.Id import com.daml.grpc.adapter.{ExecutionSequencerFactory, PekkoExecutionSequencerPool} import com.daml.nonempty.NonEmpty import com.digitalasset.canton.DiscardOps @@ -14,6 +14,7 @@ import com.digitalasset.canton.lifecycle.{FutureUnlessShutdown, UnlessShutdown} import com.digitalasset.canton.logging.pretty.Pretty import com.digitalasset.canton.logging.{HasLoggerName, NamedLoggingContext} import com.digitalasset.canton.util.ShowUtil.* +import com.digitalasset.canton.util.SingletonTraverse.syntax.* import com.digitalasset.canton.util.Thereafter.syntax.* import com.digitalasset.canton.util.TryUtil.* import com.typesafe.config.ConfigFactory @@ -44,6 +45,7 @@ import org.apache.pekko.{Done, NotUsed} import java.util.concurrent.atomic.{AtomicBoolean, AtomicReference} import scala.collection.concurrent.TrieMap +import scala.collection.immutable import scala.concurrent.duration.FiniteDuration import scala.concurrent.{ExecutionContext, Future, Promise} import scala.language.implicitConversions @@ -64,7 +66,7 @@ object PekkoUtil extends HasLoggerName { mat: Materializer ): T = { val tmp = graph - .addAttributes(ActorAttributes.withSupervisionStrategy { ex => + .addAttributes(ActorAttributes.supervisionStrategy { ex => reporter(ex) Supervision.Stop }) @@ -236,7 +238,7 @@ object PekkoUtil extends HasLoggerName { case (oldState @ Some(s), next) => // Since the context contains at most one element, it is fine to use traverse with futures here @SuppressWarnings(Array("com.digitalasset.canton.FutureTraverse")) - val resultF = Context.traverseSingleton(next)(f(s, _, _).unwrap) + val resultF = next.traverseSingleton(f(s, _, _).unwrap) resultF.map { contextualizedStateAndResult => // Since the type class ensures that the context `next` contains at most one element, // we can look for the last element in the context `result`. @@ -576,6 +578,20 @@ object PekkoUtil extends HasLoggerName { .map { case (a, ref) => WithKillSwitch(a)(ref.get()) } } + /** Drops the first `count` many elements from the `graph` that satisfy the `condition`. + * Keeps all elements that do not satisfy the `condition`. + */ + def dropIf[A, Mat](graph: FlowOps[A, Mat], count: Int, condition: A => Boolean): graph.Repr[A] = + graph.statefulMapConcat(() => { + @SuppressWarnings(Array("org.wartremover.warts.Var")) + var remaining = count + elem => + if (remaining > 0 && condition(elem)) { + remaining -= 1 + Seq.empty + } else Seq(elem) + }) + private[util] def withMaterializedValueMat[M, A, Mat, Mat2](create: => M)( graph: FlowOpsMat[A, Mat] )(combine: (Mat, M) => Mat2): graph.ReprMat[(A, M), Mat2] = @@ -663,35 +679,16 @@ object PekkoUtil extends HasLoggerName { * (Equality ignores the [[org.apache.pekko.stream.KillSwitch]]es because it is usually not very meaningful. * The [[org.apache.pekko.stream.KillSwitch]] is therefore in the second argument list.) */ - final case class WithKillSwitch[+A](private val value: A)(val killSwitch: KillSwitch) { - def unwrap: A = value - def map[B](f: A => B): WithKillSwitch[B] = copy(f(value)) - def traverse[F[_], B](f: A => F[B])(implicit F: Functor[F]): F[WithKillSwitch[B]] = - F.map(f(value))(copy) - def copy[B](value: B = this.value): WithKillSwitch[B] = WithKillSwitch(value)(killSwitch) + final case class WithKillSwitch[+A](private val value: A)(val killSwitch: KillSwitch) + extends WithGeneric[A, KillSwitch, WithKillSwitch] { + override def unwrap: A = value + override protected def added: KillSwitch = killSwitch + override protected def update[AA](newValue: AA): WithKillSwitch[AA] = copy(newValue)(killSwitch) } - object WithKillSwitch { + object WithKillSwitch extends WithGenericCompanion { implicit val singletonTraverseWithKillSwitch : SingletonTraverse.Aux[WithKillSwitch, KillSwitch] = - new SingletonTraverse[WithKillSwitch] { - override type Context = KillSwitch - - override def traverseSingleton[G[_], A, B](x: WithKillSwitch[A])( - f: (KillSwitch, A) => G[B] - )(implicit G: Applicative[G]): G[WithKillSwitch[B]] = - x.traverse(f(x.killSwitch, _)) - - override def traverse[F[_], A, B](fa: WithKillSwitch[A])(f: A => F[B])(implicit - F: Applicative[F] - ): F[WithKillSwitch[B]] = fa.traverse(f) - - override def foldLeft[A, B](fa: WithKillSwitch[A], b: B)(f: (B, A) => B): B = - f(b, fa.unwrap) - - override def foldRight[A, B](fa: WithKillSwitch[A], lb: Eval[B])( - f: (A, Eval[B]) => Eval[B] - ): Eval[B] = f(fa.unwrap, lb) - } + singletonTraverseWithGeneric[KillSwitch, WithKillSwitch] } /** Passes through all elements of the source until and including the first element that satisfies the condition. @@ -724,6 +721,25 @@ object PekkoUtil extends HasLoggerName { } }) + val noOpKillSwitch = new KillSwitch { + override def shutdown(): Unit = () + override def abort(ex: Throwable): Unit = () + } + + /** Delegates to a future [[org.apache.pekko.stream.KillSwitch]] once the kill switch becomes available. + * If both [[com.digitalasset.canton.util.PekkoUtil.DelayedKillSwitch.shutdown]] and + * [[com.digitalasset.canton.util.PekkoUtil.DelayedKillSwitch.abort]] are called or + * [[com.digitalasset.canton.util.PekkoUtil.DelayedKillSwitch.abort]] is called multiple times before the delegate + * is available, then the winning call is non-deterministic. + */ + class DelayedKillSwitch(delegate: Future[KillSwitch], logger: Logger) extends KillSwitch { + private implicit val directExecutionContext: ExecutionContext = DirectExecutionContext(logger) + + override def shutdown(): Unit = delegate.onComplete(_.foreach(_.shutdown())) + + override def abort(ex: Throwable): Unit = delegate.onComplete(_.foreach(_.abort(ex))) + } + object syntax { /** Defines extension methods for [[org.apache.pekko.stream.scaladsl.FlowOpsMat]] that map to the methods defined in this class. @@ -751,6 +767,11 @@ object PekkoUtil extends HasLoggerName { )(implicit loggingContext: NamedLoggingContext): U#Repr[UnlessShutdown[T]] = PekkoUtil.statefulMapAsyncUS(graph, initial)(f) + def statefulMapAsyncUSAndDrain[S, T](initial: S)(f: (S, A) => FutureUnlessShutdown[(S, T)])( + implicit loggingContext: NamedLoggingContext + ): U#Repr[T] = + PekkoUtil.statefulMapAsyncUSAndDrain(graph, initial)(f) + def mapAsyncUS[B](parallelism: Int)(f: A => FutureUnlessShutdown[B])(implicit loggingContext: NamedLoggingContext ): U#Repr[UnlessShutdown[B]] = @@ -760,6 +781,12 @@ object PekkoUtil extends HasLoggerName { f: A => FutureUnlessShutdown[B] )(implicit loggingContext: NamedLoggingContext): U#Repr[B] = PekkoUtil.mapAsyncAndDrainUS(graph, parallelism)(f) + + def batchN(maxBatchSize: Int, maxBatchCount: Int): U#Repr[immutable.Iterable[A]] = + graph.via(BatchN(maxBatchSize, maxBatchCount)) + + def dropIf(count: Int)(condition: A => Boolean): U#Repr[A] = + PekkoUtil.dropIf(graph, count, condition) } // Use separate implicit conversions for Sources and Flows to help IntelliJ // Otherwise IntelliJ gets very resource hungry. diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/SingletonTraverse.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/SingletonTraverse.scala index 0e3d2febb2..96c07d19a2 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/SingletonTraverse.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/SingletonTraverse.scala @@ -33,8 +33,8 @@ trait SingletonTraverse[F[_]] extends Traverse[F] { self => * The default [[org.apache.pekko.stream.scaladsl.Keep.both]] retains both contexts. */ // Partial application to help with type inference: - // The implicit `G` defines one parameter type of the combination function. - def composeWith[G[_]](implicit + // The parameter `G` defines one parameter type of the combination function. + def composeWith[G[_]]( G: SingletonTraverse[G] ): SingletonTraverse.ComposeSingletonTraversePartiallyApplied[F, G, Context, G.Context] = new SingletonTraverse.ComposeSingletonTraversePartiallyApplied[F, G, Context, G.Context]( @@ -178,4 +178,22 @@ object SingletonTraverse { G.traverseSingleton(ga)((gc, x) => f((fc, gc), x)) })(Nested.apply) } + + trait Ops[F[_], C, A] extends Serializable { + protected def self: F[A] + protected val typeClassInstance: SingletonTraverse.Aux[F, C] + def traverseSingleton[G[_], B](f: (C, A) => G[B])(implicit G: Applicative[G]): G[F[B]] = + typeClassInstance.traverseSingleton(self)(f) + } + + /** Extension method for instances of [[SingletonTraverse]]. */ + object syntax { + import scala.language.implicitConversions + implicit def SingletonTraverseOps[F[_], A](target: F[A])(implicit + st: SingletonTraverse[F] + ): Ops[F, st.Context, A] = new Ops[F, st.Context, A] { + override val self: F[A] = target + override protected val typeClassInstance: SingletonTraverse.Aux[F, st.Context] = st + } + } } diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/Thereafter.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/Thereafter.scala index f0904dca51..c800642b30 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/Thereafter.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/Thereafter.scala @@ -146,7 +146,7 @@ object Thereafter { trait Ops[F[_], C[_], A] extends Serializable { protected def self: F[A] - val typeClassInstance: Thereafter.Aux[F, C] + protected val typeClassInstance: Thereafter.Aux[F, C] def thereafter(body: C[A] => Unit)(implicit ec: ExecutionContext): F[A] = typeClassInstance.thereafter(self)(body) def thereafterF(body: C[A] => Future[Unit])(implicit ec: ExecutionContext): F[A] = @@ -160,7 +160,7 @@ object Thereafter { tc: Thereafter[F] ): Ops[F, tc.Content, A] = new Ops[F, tc.Content, A] { override val self: F[A] = target - override val typeClassInstance: Thereafter.Aux[F, tc.Content] = tc + override protected val typeClassInstance: Thereafter.Aux[F, tc.Content] = tc } } } diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/WithGeneric.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/WithGeneric.scala new file mode 100644 index 0000000000..f3b7b6c404 --- /dev/null +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/util/WithGeneric.scala @@ -0,0 +1,46 @@ +// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.util + +import cats.{Applicative, Eval, Functor} + +/** Generic implementation for creating a container of single `A`s paired with a value of type `B` + * with appropriate `map` and `traverse` implementations. + */ +trait WithGeneric[+A, B, C[+_]] { + protected def unwrap: A + protected def added: B + protected def update[AA](newValue: AA): C[AA] + + // Copies of the above protected methods + // so that we can access them from the companion object without having to make them widely visible + private[util] def unwrapInternal: A = unwrap + private[util] def addedInternal: B = added + private[util] def updateInternal[AA](newValue: AA): C[AA] = update(newValue) + + def map[AA](f: A => AA): C[AA] = update(f(unwrap)) + def traverse[F[_], AA](f: A => F[AA])(implicit F: Functor[F]): F[C[AA]] = + F.map(f(unwrap))(updateInternal) +} + +trait WithGenericCompanion { + def singletonTraverseWithGeneric[B, X[+A] <: WithGeneric[A, B, X]]: SingletonTraverse.Aux[X, B] = + new SingletonTraverse[X] { + override type Context = B + + override def traverseSingleton[G[_], A, AA](x: X[A])(f: (B, A) => G[AA])(implicit + G: Applicative[G] + ): G[X[AA]] = + G.map(f(x.addedInternal, x.unwrapInternal))(x.updateInternal) + + override def traverse[G[_], A, AA](fa: X[A])(f: A => G[AA])(implicit + G: Applicative[G] + ): G[X[AA]] = fa.traverse(f) + + override def foldLeft[A, S](fa: X[A], s: S)(f: (S, A) => S): S = f(s, fa.unwrapInternal) + + override def foldRight[A, S](fa: X[A], ls: Eval[S])(f: (A, Eval[S]) => Eval[S]): Eval[S] = + f(fa.unwrapInternal, ls) + } +} diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/version/HasProtocolVersionedWrapper.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/version/HasProtocolVersionedWrapper.scala index 7b71c6fe91..9bec086943 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/version/HasProtocolVersionedWrapper.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/version/HasProtocolVersionedWrapper.scala @@ -403,7 +403,7 @@ trait HasSupportedProtoVersions[ValueClass] { } object VersionedProtoConverter { - def apply[ProtoClass <: scalapb.GeneratedMessage, Status <: ProtocolVersion.Status]( + def apply[ProtoClass <: scalapb.GeneratedMessage, Status <: ProtocolVersionAnnotation.Status]( fromInclusive: ProtocolVersion.ProtocolVersionWithStatus[Status] )( protoCompanion: scalapb.GeneratedMessageCompanion[ProtoClass] & Status diff --git a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/version/ProtocolVersion.scala b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/version/ProtocolVersion.scala index aebe6bee02..c8c4d72f01 100644 --- a/canton-3x/community/base/src/main/scala/com/digitalasset/canton/version/ProtocolVersion.scala +++ b/canton-3x/community/base/src/main/scala/com/digitalasset/canton/version/ProtocolVersion.scala @@ -35,7 +35,7 @@ import slick.jdbc.{GetResult, PositionedParameters, SetParameter} * {{{lazy val v: ProtocolVersionWithStatus[Unstable] = ProtocolVersion.unstable()}}} * * - The new protocol version should be declared as unstable until it is released: - * Define it with type argument [[com.digitalasset.canton.version.ProtocolVersion.Unstable]] + * Define it with type argument [[com.digitalasset.canton.version.ProtocolVersionAnnotation.Unstable]] * and add it to the list in [[com.digitalasset.canton.version.ProtocolVersion.unstable]]. * * - Add a new test job for the protocol version `N` to the canton_build workflow. @@ -45,7 +45,7 @@ import slick.jdbc.{GetResult, PositionedParameters, SetParameter} * * How to release a protocol version `N`: * - Switch the type parameter of the protocol version constant `v` from - * [[com.digitalasset.canton.version.ProtocolVersion.Unstable]] to [[com.digitalasset.canton.version.ProtocolVersion.Stable]] + * [[com.digitalasset.canton.version.ProtocolVersionAnnotation.Unstable]] to [[com.digitalasset.canton.version.ProtocolVersionAnnotation.Stable]] * As a result, you may have to modify a couple of protobuf definitions and mark them as stable as well. * * - Remove `v` from [[com.digitalasset.canton.version.ProtocolVersion.unstable]] @@ -63,7 +63,7 @@ import slick.jdbc.{GetResult, PositionedParameters, SetParameter} sealed case class ProtocolVersion private[version] (v: Int) extends Ordered[ProtocolVersion] with PrettyPrinting { - type Status <: ProtocolVersion.Status + type Status <: ProtocolVersionAnnotation.Status def isDeprecated: Boolean = deprecated.contains(this) @@ -88,24 +88,20 @@ sealed case class ProtocolVersion private[version] (v: Int) } object ProtocolVersion { + type ProtocolVersionWithStatus[S <: ProtocolVersionAnnotation.Status] = ProtocolVersion { + type Status = S + } - /** Type-level marker for whether a protocol version is stable */ - sealed trait Status + private[version] def stable(v: Int): ProtocolVersionWithStatus[ProtocolVersionAnnotation.Stable] = + createWithStatus[ProtocolVersionAnnotation.Stable](v) + private[version] def unstable( + v: Int + ): ProtocolVersionWithStatus[ProtocolVersionAnnotation.Unstable] = + createWithStatus[ProtocolVersionAnnotation.Unstable](v) - /** Marker for unstable protocol versions */ - sealed trait Unstable extends Status - - /** Marker for stable protocol versions */ - sealed trait Stable extends Status - - type ProtocolVersionWithStatus[S <: Status] = ProtocolVersion { type Status = S } - - private[version] def stable(v: Int): ProtocolVersionWithStatus[Stable] = - createWithStatus[Stable](v) - private[version] def unstable(v: Int): ProtocolVersionWithStatus[Unstable] = - createWithStatus[Unstable](v) - - private def createWithStatus[S <: Status](v: Int): ProtocolVersionWithStatus[S] = + private def createWithStatus[S <: ProtocolVersionAnnotation.Status]( + v: Int + ): ProtocolVersionWithStatus[S] = new ProtocolVersion(v) { override type Status = S } implicit val protocolVersionWriter: ConfigWriter[ProtocolVersion] = @@ -262,7 +258,7 @@ object ProtocolVersion { ProtocolVersion(6), ) - val unstable: NonEmpty[List[ProtocolVersionWithStatus[Unstable]]] = + val unstable: NonEmpty[List[ProtocolVersionWithStatus[ProtocolVersionAnnotation.Unstable]]] = NonEmpty.mk(List, ProtocolVersion.v30, ProtocolVersion.dev) val supported: NonEmpty[List[ProtocolVersion]] = (unstable ++ stableAndSupported).sorted @@ -270,9 +266,11 @@ object ProtocolVersion { // TODO(i15561): change back to `stableAndSupported.max1` once there is a stable Daml 3 protocol version val latest: ProtocolVersion = stableAndSupported.lastOption.getOrElse(unstable.head1) - lazy val dev: ProtocolVersionWithStatus[Unstable] = ProtocolVersion.unstable(Int.MaxValue) + lazy val dev: ProtocolVersionWithStatus[ProtocolVersionAnnotation.Unstable] = + ProtocolVersion.unstable(Int.MaxValue) - lazy val v30: ProtocolVersionWithStatus[Unstable] = ProtocolVersion.unstable(30) + lazy val v30: ProtocolVersionWithStatus[ProtocolVersionAnnotation.Unstable] = + ProtocolVersion.unstable(30) // Minimum stable protocol version introduced lazy val minimum: ProtocolVersion = v30 @@ -318,22 +316,3 @@ object ProtoVersion { implicit val protoVersionOrdering: Ordering[ProtoVersion] = Ordering.by[ProtoVersion, Int](_.v) } - -/** Marker trait for Protobuf messages generated by scalapb - * that are used in some [[com.digitalasset.canton.version.ProtocolVersion.isStable stable]] protocol versions - * - * Implements both [[com.digitalasset.canton.version.ProtocolVersion.Stable]] and [[com.digitalasset.canton.version.ProtocolVersion.Unstable]] - * means that [[StableProtoVersion]] messages can be used in stable and unstable protocol versions. - */ -trait StableProtoVersion extends ProtocolVersion.Stable with ProtocolVersion.Unstable - -/** Marker trait for Protobuf messages generated by scalapb - * that are used only in [[com.digitalasset.canton.version.ProtocolVersion.isUnstable unstable]] protocol versions - */ -trait UnstableProtoVersion extends ProtocolVersion.Unstable - -/** Marker trait for Protobuf messages generated by scalapb - * that are used only to persist data in node storage. - * These messages are never exchanged as part of a protocol. - */ -trait StorageProtoVersion diff --git a/canton-3x/community/common/src/main/daml/CantonExamples/daml.yaml b/canton-3x/community/common/src/main/daml/CantonExamples/daml.yaml index 001743926d..e6afaefe37 100644 --- a/canton-3x/community/common/src/main/daml/CantonExamples/daml.yaml +++ b/canton-3x/community/common/src/main/daml/CantonExamples/daml.yaml @@ -1,10 +1,10 @@ -sdk-version: 2.9.0-snapshot.20231215.12512.0.v5a0f0a18 -build-options: +sdk-version: 2.9.0-snapshot.20231231.12528.0.vca9bd5e6 +build-options: - --target=2.1 name: CantonExamples source: . version: 3.0.0 -dependencies: +dependencies: - daml-prim - daml-stdlib - daml3-script diff --git a/canton-3x/community/common/src/main/scala/com/digitalasset/canton/admin/grpc/GrpcPruningScheduler.scala b/canton-3x/community/common/src/main/scala/com/digitalasset/canton/admin/grpc/GrpcPruningScheduler.scala index 710447aa9b..f2f17fab15 100644 --- a/canton-3x/community/common/src/main/scala/com/digitalasset/canton/admin/grpc/GrpcPruningScheduler.scala +++ b/canton-3x/community/common/src/main/scala/com/digitalasset/canton/admin/grpc/GrpcPruningScheduler.scala @@ -6,8 +6,8 @@ package com.digitalasset.canton.admin.grpc import cats.data.EitherT import cats.syntax.bifunctor.* import com.digitalasset.canton.ProtoDeserializationError.ProtoDeserializationFailure +import com.digitalasset.canton.admin.pruning.v0 import com.digitalasset.canton.logging.NamedLogging -import com.digitalasset.canton.pruning.admin.v0 import com.digitalasset.canton.resource.DbStorage.PassiveInstanceException import com.digitalasset.canton.scheduler.{Cron, PruningSchedule, PruningScheduler} import com.digitalasset.canton.serialization.ProtoConverter diff --git a/canton-3x/community/common/src/main/scala/com/digitalasset/canton/common/domain/SequencerConnectClient.scala b/canton-3x/community/common/src/main/scala/com/digitalasset/canton/common/domain/SequencerConnectClient.scala index 4ec017a2ac..ea75ca7340 100644 --- a/canton-3x/community/common/src/main/scala/com/digitalasset/canton/common/domain/SequencerConnectClient.scala +++ b/canton-3x/community/common/src/main/scala/com/digitalasset/canton/common/domain/SequencerConnectClient.scala @@ -33,6 +33,12 @@ trait SequencerConnectClient extends NamedLogging with AutoCloseable { traceContext: TraceContext ): EitherT[Future, Error, StaticDomainParameters] + /** @param domainIdentifier Used for logging purpose + */ + def getDomainId(domainIdentifier: String)(implicit + traceContext: TraceContext + ): EitherT[Future, Error, DomainId] + def handshake( domainAlias: DomainAlias, request: HandshakeRequest, diff --git a/canton-3x/community/common/src/main/scala/com/digitalasset/canton/common/domain/grpc/GrpcSequencerConnectClient.scala b/canton-3x/community/common/src/main/scala/com/digitalasset/canton/common/domain/grpc/GrpcSequencerConnectClient.scala index fb44a2078a..e893dd7170 100644 --- a/canton-3x/community/common/src/main/scala/com/digitalasset/canton/common/domain/grpc/GrpcSequencerConnectClient.scala +++ b/canton-3x/community/common/src/main/scala/com/digitalasset/canton/common/domain/grpc/GrpcSequencerConnectClient.scala @@ -107,6 +107,29 @@ class GrpcSequencerConnectClient( } yield domainParameters + override def getDomainId( + domainIdentifier: String + )(implicit traceContext: TraceContext): EitherT[Future, Error, DomainId] = for { + responseP <- CantonGrpcUtil + .sendSingleGrpcRequest( + serverName = domainIdentifier, + requestDescription = "get domain id", + channel = builder.build(), + stubFactory = v0.SequencerConnectServiceGrpc.stub, + timeout = timeouts.network.unwrap, + logger = logger, + logPolicy = CantonGrpcUtil.silentLogPolicy, + retryPolicy = CantonGrpcUtil.RetryPolicy.noRetry, + )(_.getDomainId(v0.SequencerConnect.GetDomainId.Request())) + .leftMap(err => Error.Transport(err.toString)) + + domainId <- EitherT + .fromEither[Future]( + DomainId.fromProtoPrimitive(responseP.domainId, "domain_id") + ) + .leftMap[Error](err => Error.DeserializationFailure(err.toString)) + } yield domainId + override def handshake( domainAlias: DomainAlias, request: HandshakeRequest, diff --git a/canton-3x/community/common/src/main/scala/com/digitalasset/canton/scheduler/Schedule.scala b/canton-3x/community/common/src/main/scala/com/digitalasset/canton/scheduler/Schedule.scala index 67fd4d0f26..45961d44dc 100644 --- a/canton-3x/community/common/src/main/scala/com/digitalasset/canton/scheduler/Schedule.scala +++ b/canton-3x/community/common/src/main/scala/com/digitalasset/canton/scheduler/Schedule.scala @@ -5,9 +5,9 @@ package com.digitalasset.canton.scheduler import cats.syntax.either.* import com.digitalasset.canton.ProtoDeserializationError.ValueConversionError +import com.digitalasset.canton.admin.pruning.v0 import com.digitalasset.canton.config.CantonRequireTypes.String300 import com.digitalasset.canton.data.CantonTimestamp -import com.digitalasset.canton.pruning.admin.v0 import com.digitalasset.canton.scheduler.Cron.* import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult import com.digitalasset.canton.time.PositiveSeconds diff --git a/canton-3x/community/common/src/main/scala/com/digitalasset/canton/topology/QueueBasedDomainOutboxX.scala b/canton-3x/community/common/src/main/scala/com/digitalasset/canton/topology/QueueBasedDomainOutboxX.scala index 1ba6420fcd..48307cab6c 100644 --- a/canton-3x/community/common/src/main/scala/com/digitalasset/canton/topology/QueueBasedDomainOutboxX.scala +++ b/canton-3x/community/common/src/main/scala/com/digitalasset/canton/topology/QueueBasedDomainOutboxX.scala @@ -55,7 +55,9 @@ class QueueBasedDomainOutboxX( )(implicit traceContext: TraceContext): FutureUnlessShutdown[Boolean] = TopologyStoreX.awaitTxObserved(targetClient, transaction, targetStore, timeout) - protected def findPendingTransactions(): Future[Seq[GenericSignedTopologyTransactionX]] = { + protected def findPendingTransactions()(implicit + traceContext: TraceContext + ): Future[Seq[GenericSignedTopologyTransactionX]] = { Future.successful( domainOutboxQueue .dequeue(batchSize) @@ -106,11 +108,7 @@ class QueueBasedDomainOutboxX( queuedApprox = queuedApprox + queuedNum ) if (ret.hasPending) { - idleFuture.updateAndGet { - case None => - Some(Promise()) - case x => x - } + ensureIdleFutureIsSet() } ret } @@ -209,12 +207,28 @@ class QueueBasedDomainOutboxX( traceContext: TraceContext ): EitherT[FutureUnlessShutdown, String, Unit] = { def markDone(delayRetry: Boolean = false): Unit = { - val updated = queueState.getAndUpdate(_.done()) + val updated = queueState.updateAndGet(_.done()) // if anything has been pushed in the meantime, we need to kick off a new flush + logger.debug( + s"Marked flush as done. Updated queue size: ${updated.queuedApprox}. IsClosing: ${isClosing}" + ) if (updated.hasPending && !isClosing) { if (delayRetry) { - // kick off new flush in the background - DelayUtil.delay(functionFullName, 10.seconds, this).map(_ => kickOffFlush()).discard + val delay = 10.seconds + logger.debug(s"Kick off a new delayed flush in ${delay}") + DelayUtil + .delay(functionFullName, delay, this) + .map { _ => + if (!isClosing) { + logger.debug(s"About to kick off a delayed flush scheduled ${delay} ago") + kickOffFlush() + } else { + logger.debug( + s"Queue-based outbox is now closing. Ignoring delayed flushed schedule ${delay} ago" + ) + } + } + .discard } else { kickOffFlush() } @@ -223,8 +237,14 @@ class QueueBasedDomainOutboxX( val cur = queueState.getAndUpdate(_.setRunning()) + logger.debug(s"Invoked flush with queue size ${queueState.get().queuedApprox}") + + if (isClosing) { + logger.debug("Flush invoked in spite of closing") + EitherT.rightT(()) + } // only flush if we are not running yet - if (cur.running) { + else if (cur.running) { logger.debug("Another flush cycle is currently ongoing") EitherT.rightT(()) } else { @@ -277,11 +297,24 @@ class QueueBasedDomainOutboxX( markDone() } - EitherTUtil.onErrorOrFailureOrShutdown { () => - domainOutboxQueue.requeue() - markDone(delayRetry = true) - }(ret) + EitherTUtil.onErrorOrFailureUnlessShutdown[String, Unit]( + errorHandler = either => { + val errorDetails = either.fold( + throwable => s"exception ${throwable.getMessage}", + error => s"error $error", + ) + logger.info(s"Requeuing and backing off due to $errorDetails") + domainOutboxQueue.requeue() + markDone(delayRetry = true) + }, + shutdownHandler = () => { + logger.info(s"Requeuing and stopping due to closing/domain-disconnect") + domainOutboxQueue.requeue() + markDone() + }, + )(ret) } else { + logger.debug("Nothing pending. Marking as done.") markDone() EitherT.rightT(()) } @@ -309,9 +342,11 @@ class QueueBasedDomainOutboxX( ) .unlessShutdown( { - logger.debug( - s"Attempting to push ${transactions.size} topology transactions to $domain" - ) + if (logger.underlying.isDebugEnabled()) { + logger.debug( + s"Attempting to push ${transactions.size} topology transactions to $domain, specifically: ${transactions}" + ) + } FutureUtil.logOnFailureUnlessShutdown( handle.submit(transactions), s"Pushing topology transactions to $domain", @@ -325,11 +360,14 @@ class QueueBasedDomainOutboxX( s"Topology request contained ${transactions.length} txs, but I received responses for ${responses.length}" ) } - logger.debug( - s"$domain responded the following for the given topology transactions: $responses" - ) + val responsesWithTransactions = responses.zip(transactions) + if (logger.underlying.isDebugEnabled()) { + logger.debug( + s"$domain responded the following for the given topology transactions: $responsesWithTransactions" + ) + } val failedResponses = - responses.zip(transactions).collect { + responsesWithTransactions.collect { case (TopologyTransactionsBroadcastX.State.Failed, tx) => tx } diff --git a/canton-3x/community/common/src/main/scala/com/digitalasset/canton/traffic/MemberTrafficStatus.scala b/canton-3x/community/common/src/main/scala/com/digitalasset/canton/traffic/MemberTrafficStatus.scala index 6531cf9655..eddfb7bfe3 100644 --- a/canton-3x/community/common/src/main/scala/com/digitalasset/canton/traffic/MemberTrafficStatus.scala +++ b/canton-3x/community/common/src/main/scala/com/digitalasset/canton/traffic/MemberTrafficStatus.scala @@ -5,11 +5,11 @@ package com.digitalasset.canton.traffic import cats.syntax.traverse.* import com.digitalasset.canton.ProtoDeserializationError +import com.digitalasset.canton.admin.traffic.v0.MemberTrafficStatus as MemberTrafficStatusP import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.sequencing.protocol.SequencedEventTrafficState import com.digitalasset.canton.serialization.ProtoConverter import com.digitalasset.canton.topology.Member -import com.digitalasset.canton.traffic.v0.MemberTrafficStatus as MemberTrafficStatusP final case class MemberTrafficStatus( member: Member, diff --git a/canton-3x/community/common/src/main/scala/com/digitalasset/canton/traffic/TopUpEvent.scala b/canton-3x/community/common/src/main/scala/com/digitalasset/canton/traffic/TopUpEvent.scala index c5aedb4415..23e20399d3 100644 --- a/canton-3x/community/common/src/main/scala/com/digitalasset/canton/traffic/TopUpEvent.scala +++ b/canton-3x/community/common/src/main/scala/com/digitalasset/canton/traffic/TopUpEvent.scala @@ -4,10 +4,10 @@ package com.digitalasset.canton.traffic import com.digitalasset.canton.ProtoDeserializationError +import com.digitalasset.canton.admin.traffic.v0.MemberTrafficStatus.TopUpEvent as TopUpEventP import com.digitalasset.canton.config.RequireTypes.{NonNegativeLong, PositiveInt, PositiveLong} import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.serialization.ProtoConverter -import com.digitalasset.canton.traffic.v0.MemberTrafficStatus.TopUpEvent as TopUpEventP import slick.jdbc.GetResult object TopUpEvent { diff --git a/canton-3x/community/common/src/test/scala/com/digitalasset/canton/sequencing/SequencedEventMonotonicityCheckerTest.scala b/canton-3x/community/common/src/test/scala/com/digitalasset/canton/sequencing/SequencedEventMonotonicityCheckerTest.scala index 9b0696ba62..6cede3bf72 100644 --- a/canton-3x/community/common/src/test/scala/com/digitalasset/canton/sequencing/SequencedEventMonotonicityCheckerTest.scala +++ b/canton-3x/community/common/src/test/scala/com/digitalasset/canton/sequencing/SequencedEventMonotonicityCheckerTest.scala @@ -120,11 +120,12 @@ class SequencedEventMonotonicityCheckerTest loggerFactory, ) val eventsF = Source(bobEvents) + .map(Right(_)) .withUniqueKillSwitchMat()(Keep.left) .via(checker.flow) .toMat(Sink.seq)(Keep.right) .run() - eventsF.futureValue.map(_.unwrap) shouldBe bobEvents + eventsF.futureValue.map(_.unwrap) shouldBe bobEvents.map(Right(_)) } "kill the stream upon a gap in the counters" in { env => @@ -138,6 +139,7 @@ class SequencedEventMonotonicityCheckerTest val (batch1, batch2) = bobEvents.splitAt(2) val eventsF = loggerFactory.assertLogs( Source(batch1 ++ batch2.drop(1)) + .map(Right(_)) .withUniqueKillSwitchMat()(Keep.left) .via(checker.flow) .toMat(Sink.seq)(Keep.right) @@ -146,7 +148,7 @@ class SequencedEventMonotonicityCheckerTest "Sequencer counters and timestamps do not increase monotonically" ), ) - eventsF.futureValue.map(_.unwrap) shouldBe batch1 + eventsF.futureValue.map(_.unwrap) shouldBe batch1.map(Right(_)) } "detect non-monotonic timestamps" in { env => @@ -168,6 +170,7 @@ class SequencedEventMonotonicityCheckerTest ) val eventsF = loggerFactory.assertLogs( Source(Seq(event1, event2)) + .map(Right(_)) .withUniqueKillSwitchMat()(Keep.left) .via(checker.flow) .toMat(Sink.seq)(Keep.right) @@ -176,7 +179,7 @@ class SequencedEventMonotonicityCheckerTest "Sequencer counters and timestamps do not increase monotonically" ), ) - eventsF.futureValue.map(_.unwrap) shouldBe Seq(event1) + eventsF.futureValue.map(_.unwrap) shouldBe Seq(Right(event1)) } } } diff --git a/canton-3x/community/common/src/test/scala/com/digitalasset/canton/sequencing/SequencerAggregatorPekkoTest.scala b/canton-3x/community/common/src/test/scala/com/digitalasset/canton/sequencing/SequencerAggregatorPekkoTest.scala index 302015b1c6..a3815fbf27 100644 --- a/canton-3x/community/common/src/test/scala/com/digitalasset/canton/sequencing/SequencerAggregatorPekkoTest.scala +++ b/canton-3x/community/common/src/test/scala/com/digitalasset/canton/sequencing/SequencerAggregatorPekkoTest.scala @@ -367,7 +367,7 @@ class SequencerAggregatorPekkoTest source.offer(config1) shouldBe QueueOfferResult.Enqueued sink.request(10) - sink.expectNext() shouldBe Left(NewConfiguration(config1, initialCounter)) + sink.expectNext() shouldBe Left(NewConfiguration(config1, initialCounter - 1L)) normalize(sink.expectNext().value) shouldBe normalize( Event( initialCounter + 1, diff --git a/canton-3x/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/ResilientSequencerSubscriberPekkoTest.scala b/canton-3x/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/ResilientSequencerSubscriberPekkoTest.scala index 72b2894fc8..587b96fa6d 100644 --- a/canton-3x/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/ResilientSequencerSubscriberPekkoTest.scala +++ b/canton-3x/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/ResilientSequencerSubscriberPekkoTest.scala @@ -214,7 +214,7 @@ class ResilientSequencerSubscriberPekkoTest extends StreamSpec with BaseTest { val (killSwitch, doneF) = subscription.source.toMat(Sink.ignore)(Keep.left).run() // we retry until we become unhealthy - eventually() { + eventually(maxPollInterval = 10.milliseconds) { subscription.health.isFailed shouldBe true } diff --git a/canton-3x/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/SequencedEventValidatorTest.scala b/canton-3x/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/SequencedEventValidatorTest.scala index 31ca2e364f..eeaf3a8fa3 100644 --- a/canton-3x/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/SequencedEventValidatorTest.scala +++ b/canton-3x/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/SequencedEventValidatorTest.scala @@ -14,7 +14,8 @@ import com.digitalasset.canton.sequencing.client.SequencedEventValidationError.* import com.digitalasset.canton.sequencing.protocol.{ClosedEnvelope, SequencedEvent} import com.digitalasset.canton.store.SequencedEventStore.IgnoredSequencedEvent import com.digitalasset.canton.topology.* -import com.digitalasset.canton.util.PekkoUtilTest.{noOpKillSwitch, withNoOpKillSwitch} +import com.digitalasset.canton.util.PekkoUtil.noOpKillSwitch +import com.digitalasset.canton.util.PekkoUtilTest.withNoOpKillSwitch import com.digitalasset.canton.util.ResourceUtil import com.digitalasset.canton.{BaseTest, HasExecutionContext, SequencerCounter} import com.google.protobuf.ByteString diff --git a/canton-3x/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/SequencerClientTest.scala b/canton-3x/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/SequencerClientTest.scala index 084781259b..7d886afaa1 100644 --- a/canton-3x/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/SequencerClientTest.scala +++ b/canton-3x/community/common/src/test/scala/com/digitalasset/canton/sequencing/client/SequencerClientTest.scala @@ -14,13 +14,18 @@ import com.digitalasset.canton.config.* import com.digitalasset.canton.crypto.provider.symbolic.SymbolicCrypto import com.digitalasset.canton.crypto.{CryptoPureApi, Fingerprint, HashPurpose} import com.digitalasset.canton.data.CantonTimestamp +import com.digitalasset.canton.health.HealthComponent.AlwaysHealthyComponent import com.digitalasset.canton.lifecycle.{CloseContext, FutureUnlessShutdown, UnlessShutdown} -import com.digitalasset.canton.logging.pretty.Pretty +import com.digitalasset.canton.logging.pretty.{Pretty, PrettyInstances} import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging, NamedLoggingContext} import com.digitalasset.canton.metrics.MetricHandle.NoOpMetricsFactory import com.digitalasset.canton.metrics.{CommonMockMetrics, SequencerClientMetrics} import com.digitalasset.canton.protocol.messages.DefaultOpenEnvelope -import com.digitalasset.canton.protocol.{DomainParametersLookup, TestDomainParameters} +import com.digitalasset.canton.protocol.{ + DomainParametersLookup, + DynamicDomainParametersLookup, + TestDomainParameters, +} import com.digitalasset.canton.sequencing.* import com.digitalasset.canton.sequencing.client.SequencedEventValidationError.GapInSequencerCounter import com.digitalasset.canton.sequencing.client.SequencerClient.CloseReason.{ @@ -60,12 +65,16 @@ import com.digitalasset.canton.topology.DefaultTestIdentities.{participant1, seq import com.digitalasset.canton.topology.* import com.digitalasset.canton.topology.client.{DomainTopologyClient, TopologySnapshot} import com.digitalasset.canton.tracing.TraceContext -import com.digitalasset.canton.util.MonadUtil +import com.digitalasset.canton.util.PekkoUtil.syntax.* import com.digitalasset.canton.version.ProtocolVersion +import org.apache.pekko.actor.ActorSystem +import org.apache.pekko.stream.scaladsl.{Keep, Source} +import org.apache.pekko.stream.{BoundedSourceQueue, Materializer, QueueOfferResult} +import org.scalatest.BeforeAndAfterAll import org.scalatest.wordspec.AnyWordSpec import java.util.concurrent.ConcurrentLinkedQueue -import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger, AtomicReference} +import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger, AtomicLong, AtomicReference} import scala.annotation.tailrec import scala.concurrent.duration.Duration import scala.concurrent.{ExecutionContext, Future, Promise, blocking} @@ -76,7 +85,8 @@ class SequencerClientTest extends AnyWordSpec with BaseTest with HasExecutionContext - with CloseableTest { + with CloseableTest + with BeforeAndAfterAll { private lazy val metrics = new SequencerClientMetrics( @@ -110,6 +120,19 @@ class SequencerClientTest DefaultTestIdentities.domainId, ) + private var actorSystem: ActorSystem = _ + private lazy val materializer: Materializer = Materializer(actorSystem) + + override protected def beforeAll(): Unit = { + super.beforeAll() + actorSystem = ActorSystem("SequencerClientTest") + } + + override def afterAll(): Unit = { + actorSystem.terminate().futureValue + super.afterAll() + } + def deliver(i: Long): Deliver[Nothing] = SequencerTestUtils.mockDeliver( i, CantonTimestamp.Epoch.plusSeconds(i), @@ -124,83 +147,36 @@ class SequencerClientTest HandlerResult.synchronous(FutureUnlessShutdown.failed(failureException)) ) - "subscribe" should { - "throws if more than one handler is subscribed" in { - (for { - env <- Env.create() - _ <- env.subscribeAfter() - error <- loggerFactory - .assertLogs( - env.subscribeAfter(CantonTimestamp.MinValue, alwaysSuccessfulHandler), - _.warningMessage shouldBe "Cannot create additional subscriptions to the sequencer from the same client", - ) - .failed - } yield error).futureValue shouldBe a[RuntimeException] - } + private def sequencerClient(factory: EnvFactory[SequencerClient]): Unit = { + "subscribe" should { + "throws if more than one handler is subscribed" in { + val env = factory.create() + env.subscribeAfter().futureValue + loggerFactory.assertLogs( + env.subscribeAfter(CantonTimestamp.MinValue, alwaysSuccessfulHandler).failed.futureValue, + _.warningMessage shouldBe "Cannot create additional subscriptions to the sequencer from the same client", + _.errorMessage should include("Sequencer subscription failed"), + ) shouldBe a[RuntimeException] + } - "start from the specified sequencer counter if there is no recorded event" in { - val counterF = for { - env <- Env.create(initialSequencerCounter = SequencerCounter(5)) - _ <- env.subscribeAfter() - } yield env.transport.subscriber.value.request.counter + "start from the specified sequencer counter if there is no recorded event" in { + val env = factory.create(initialSequencerCounter = SequencerCounter(5)) + env.subscribeAfter().futureValue + val counter = env.transport.subscriber.value.request.counter + counter shouldBe SequencerCounter(5) + } - counterF.futureValue shouldBe SequencerCounter(5) - } + "starts subscription at last stored event (for fork verification)" in { + val env = factory.create(storedEvents = Seq(deliver)) + env.subscribeAfter().futureValue + val counter = env.transport.subscriber.value.request.counter + counter shouldBe deliver.counter + } - "starts subscription at last stored event (for fork verification)" in { - val counterF = for { - env <- Env.create( - storedEvents = Seq(deliver) - ) - _ <- env.subscribeAfter() - } yield env.transport.subscriber.value.request.counter - - counterF.futureValue shouldBe deliver.counter - } - - "stores the event in the SequencedEventStore" in { - val storedEventF = for { - env @ Env(client, transport, _, sequencedEventStore, _) <- Env.create() - - _ <- env.subscribeAfter() - _ <- transport.subscriber.value.handler(signedDeliver) - _ <- client.flush() - storedEvent <- sequencedEventStore.sequencedEvents() - } yield storedEvent - - storedEventF.futureValue shouldBe Seq(signedDeliver) - } - - "stores the event even if the handler fails" in { - val storedEventF = for { - env @ Env(client, transport, _, sequencedEventStore, _) <- Env.create() - - _ <- env.subscribeAfter(eventHandler = alwaysFailingHandler) - _ <- loggerFactory.assertLogs( - { - for { - _ <- transport.subscriber.value.handler(signedDeliver) - _ <- client.flush() - } yield () - }, - logEntry => { - logEntry.errorMessage should be( - "Synchronous event processing failed for event batch with sequencer counters 42 to 42." - ) - logEntry.throwable.value shouldBe failureException - }, - ) - storedEvent <- sequencedEventStore.sequencedEvents() - } yield storedEvent - - storedEventF.futureValue shouldBe Seq(signedDeliver) - } - - "doesn't give prior event to the application handler" in { - val validated = new AtomicBoolean() - val processed = new AtomicBoolean() - val testF = for { - env @ Env(_client, transport, _, _, _) <- Env.create( + "doesn't give prior event to the application handler" in { + val validated = new AtomicBoolean() + val processed = new AtomicBoolean() + val env @ Env(_, transport, _, _, _) = factory.create( eventValidator = new SequencedEventValidator { override def validate( priorEvent: Option[PossiblyIgnoredSerializedEvent], @@ -208,7 +184,7 @@ class SequencerClientTest sequencerId: SequencerId, ): EitherT[FutureUnlessShutdown, SequencedEventValidationError[Nothing], Unit] = { validated.set(true) - Env.eventAlwaysValid.validate(priorEvent, event, sequencerId) + eventAlwaysValid.validate(priorEvent, event, sequencerId) } override def validateOnReconnect( @@ -224,606 +200,696 @@ class SequencerClientTest sequencerId: SequencerId, )(implicit traceContext: TraceContext - ): SequencerSubscriptionPekko[SequencedEventValidationError[E]] = ??? + ): SequencerSubscriptionPekko[SequencedEventValidationError[E]] = { + val SequencerSubscriptionPekko(source, health) = + eventAlwaysValid.validatePekko(subscription, priorReconnectEvent, sequencerId) + val observeValidation = source.map { x => + validated.set(true) + x + } + SequencerSubscriptionPekko(observeValidation, health) + } override def close(): Unit = () }, storedEvents = Seq(deliver), ) - _ <- env.subscribeAfter( - deliver.timestamp, - ApplicationHandler.create("") { events => - processed.set(true) - alwaysSuccessfulHandler(events) - }, - ) - _ = transport.subscriber.value.request.counter shouldBe deliver.counter - _ <- transport.subscriber.value.handler(signedDeliver) - } yield { - validated.get() shouldBe true - processed.get() shouldBe false - } - testF.futureValue - } - - "picks the last prior event" in { - val triggerNextDeliverHandling = new AtomicBoolean() - val testF = for { - env <- Env.create( - storedEvents = Seq(deliver, nextDeliver, deliver44) - ) - _ <- env.subscribeAfter( - nextDeliver.timestamp.immediatePredecessor, - ApplicationHandler.create("") { events => - if (events.value.exists(_.counter == nextDeliver.counter)) { - triggerNextDeliverHandling.set(true) - } - HandlerResult.done - }, - ) - } yield () - - testF.futureValue - triggerNextDeliverHandling.get shouldBe true - } - - "completes the sequencer client if the subscription closes due to an error" in { - val error = - EventValidationError(GapInSequencerCounter(SequencerCounter(666), SequencerCounter(0))) - val closeReasonF = for { - env @ Env(client, transport, _, _, _) <- Env.create() - - _ <- env.subscribeAfter(CantonTimestamp.MinValue, alwaysSuccessfulHandler) - subscription = transport.subscriber - // we know the resilient sequencer subscription is using this type - .map(_.subscription.asInstanceOf[MockSubscription[SequencerClientSubscriptionError]]) - .value - closeReason <- loggerFactory.assertLogs( - { - subscription.closeSubscription(error) - client.completion - }, - _.warningMessage should include("sequencer"), - ) - } yield closeReason - - closeReasonF.futureValue should matchPattern { - case e: UnrecoverableError if e.cause == s"handler returned error: $error" => - } - } - - "completes the sequencer client if the application handler fails" in { - val error = new RuntimeException("failed handler") - val syncError = ApplicationHandlerException(error, deliver.counter, deliver.counter) - val handler: PossiblyIgnoredApplicationHandler[ClosedEnvelope] = - ApplicationHandler.create("async-failure")(_ => - FutureUnlessShutdown.failed[AsyncResult](error) - ) - - val closeReasonF = for { - env @ Env(client, transport, _, _, _) <- Env.create() - _ <- env.subscribeAfter(CantonTimestamp.MinValue, handler) - closeReason <- loggerFactory.assertLogs( - { - for { - _ <- transport.subscriber.value.sendToHandler(deliver) - // Send the next event so that the client notices that an error has occurred. - _ <- client.flush() - _ <- transport.subscriber.value.sendToHandler(nextDeliver) - // wait until the subscription is closed (will emit an error) - closeReason <- client.completion - } yield closeReason - }, - logEntry => { - logEntry.errorMessage should be( - s"Synchronous event processing failed for event batch with sequencer counters ${deliver.counter} to ${deliver.counter}." - ) - logEntry.throwable shouldBe Some(error) - }, - _.warningMessage should include( - s"Closing resilient sequencer subscription due to error: HandlerError($syncError)" - ), - ) - - } yield { - client.close() // make sure that we can still close the sequencer client - closeReason - } - - closeReasonF.futureValue should matchPattern { - case e: UnrecoverableError if e.cause == s"handler returned error: $syncError" => - } - } - - "completes the sequencer client if the application handler shuts down synchronously" in { - val handler: PossiblyIgnoredApplicationHandler[ClosedEnvelope] = - ApplicationHandler.create("shutdown")(_ => FutureUnlessShutdown.abortedDueToShutdown) - - val closeReasonF = for { - env @ Env(client, transport, _, _, _) <- Env.create() - _ <- env.subscribeAfter(eventHandler = handler) - closeReason <- { - for { - _ <- transport.subscriber.value.sendToHandler(deliver) - // Send the next event so that the client notices that an error has occurred. - _ <- client.flush() - _ <- transport.subscriber.value.sendToHandler(nextDeliver) - closeReason <- client.completion - } yield closeReason + val testF = for { + _ <- env.subscribeAfter( + deliver.timestamp, + ApplicationHandler.create("") { events => + processed.set(true) + alwaysSuccessfulHandler(events) + }, + ) + _ = transport.subscriber.value.request.counter shouldBe deliver.counter + _ <- transport.subscriber.value.sendToHandler(signedDeliver) + } yield { + eventually() { + validated.get() shouldBe true + } + processed.get() shouldBe false } - } yield { - client.close() // make sure that we can still close the sequencer client - closeReason + + testF.futureValue } - closeReasonF.futureValue shouldBe ClientShutdown - } + "picks the last prior event" in { + val triggerNextDeliverHandling = new AtomicBoolean() + val env = factory.create(storedEvents = Seq(deliver, nextDeliver, deliver44)) + val testF = for { + _ <- env.subscribeAfter( + nextDeliver.timestamp.immediatePredecessor, + ApplicationHandler.create("") { events => + if (events.value.exists(_.counter == nextDeliver.counter)) { + triggerNextDeliverHandling.set(true) + } + HandlerResult.done + }, + ) + } yield () - "completes the sequencer client if asynchronous event processing fails" in { - val error = new RuntimeException("asynchronous failure") - val asyncFailure = HandlerResult.asynchronous(FutureUnlessShutdown.failed(error)) - val asyncException = ApplicationHandlerException(error, deliver.counter, deliver.counter) + testF.futureValue + triggerNextDeliverHandling.get shouldBe true + } - val closeReasonF = for { - env @ Env(client, transport, _, _, _) <- Env.create() - _ <- env.subscribeAfter( - eventHandler = ApplicationHandler.create("async-failure")(_ => asyncFailure) + "replays messages from the SequencedEventStore" in { + val processedEvents = new ConcurrentLinkedQueue[SequencerCounter] + + val env = factory.create(storedEvents = Seq(deliver, nextDeliver, deliver44)) + env + .subscribeAfter( + deliver.timestamp, + ApplicationHandler.create("") { events => + events.value.foreach(event => processedEvents.add(event.counter)) + alwaysSuccessfulHandler(events) + }, + ) + .futureValue + + processedEvents.iterator().asScala.toSeq shouldBe Seq( + nextDeliver.counter, + deliver44.counter, ) - closeReason <- loggerFactory.assertLogs( - { - for { - _ <- transport.subscriber.value.sendToHandler(deliver) - // Make sure that the asynchronous error has been noticed - // We intentionally do two flushes. The first captures `handleReceivedEventsUntilEmpty` completing. - // During this it may addToFlush a future for capturing `asyncSignalledF` however this may occur - // after we've called `flush` and therefore won't guarantee completing all processing. - // So our second flush will capture `asyncSignalledF` for sure. - _ <- client.flush() - _ <- client.flush() - // Send the next event so that the client notices that an error has occurred. - _ <- transport.subscriber.value.sendToHandler(nextDeliver) - _ <- client.flush() - // wait until client completed (will write an error) - closeReason <- client.completion - _ = client.close() // make sure that we can still close the sequencer client - } yield closeReason - }, + } + + "propagates errors during replay" in { + val syncError = + ApplicationHandlerException(failureException, nextDeliver.counter, nextDeliver.counter) + val syncExc = SequencerClientSubscriptionException(syncError) + + val env = factory.create(storedEvents = Seq(deliver, nextDeliver)) + + loggerFactory.assertLogs( + env.subscribeAfter(deliver.timestamp, alwaysFailingHandler).failed.futureValue, logEntry => { logEntry.errorMessage should include( - s"Asynchronous event processing failed for event batch with sequencer counters ${deliver.counter} to ${deliver.counter}" + "Synchronous event processing failed for event batch with sequencer counters 43 to 43" ) - logEntry.throwable shouldBe Some(error) - }, - _.warningMessage should include( - s"Closing resilient sequencer subscription due to error: HandlerError($asyncException)" - ), - ) - } yield closeReason - - closeReasonF.futureValue should matchPattern { - case e: UnrecoverableError if e.cause == s"handler returned error: $asyncException" => - } - } - - "completes the sequencer client if asynchronous event processing shuts down" in { - val asyncShutdown = HandlerResult.asynchronous(FutureUnlessShutdown.abortedDueToShutdown) - - val closeReasonF = for { - env @ Env(client, transport, _, _, _) <- Env.create() - _ <- env.subscribeAfter( - CantonTimestamp.MinValue, - ApplicationHandler.create("async-shutdown")(_ => asyncShutdown), - ) - closeReason <- { - for { - _ <- transport.subscriber.value.sendToHandler(deliver) - _ <- client.flushClean() // Make sure that the asynchronous error has been noticed - // Send the next event so that the client notices that an error has occurred. - _ <- transport.subscriber.value.sendToHandler(nextDeliver) - _ <- client.flush() - closeReason <- client.completion - } yield closeReason - } - } yield { - client.close() // make sure that we can still close the sequencer client - closeReason - } - - closeReasonF.futureValue shouldBe ClientShutdown - } - - "replays messages from the SequencedEventStore" in { - val processedEvents = new ConcurrentLinkedQueue[SequencerCounter] - - val testF = for { - env <- Env.create( - storedEvents = Seq(deliver, nextDeliver, deliver44) - ) - _ <- env.subscribeAfter( - deliver.timestamp, - ApplicationHandler.create("") { events => - events.value.foreach(event => processedEvents.add(event.counter)) - alwaysSuccessfulHandler(events) - }, - ) - } yield () - - testF.futureValue - - processedEvents.iterator().asScala.toSeq shouldBe Seq( - nextDeliver.counter, - deliver44.counter, - ) - } - - "propagates errors during replay" in { - val syncError = - ApplicationHandlerException(failureException, nextDeliver.counter, deliver44.counter) - val syncExc = SequencerClientSubscriptionException(syncError) - - val errorF = for { - env <- Env.create( - storedEvents = Seq(deliver, nextDeliver, deliver44) - ) - error <- loggerFactory.assertLogs( - env.subscribeAfter(deliver.timestamp, alwaysFailingHandler).failed, - logEntry => { - logEntry.errorMessage shouldBe "Synchronous event processing failed for event batch with sequencer counters 43 to 44." logEntry.throwable shouldBe Some(failureException) }, logEntry => { logEntry.errorMessage should include("Sequencer subscription failed") - logEntry.throwable shouldBe Some(syncExc) + logEntry.throwable.value shouldBe syncExc }, - ) - } yield error + ) shouldBe syncExc + } - errorF.futureValue shouldBe syncExc - } - - "throttle message batches" in { - val counter = new AtomicInteger(0) - val maxSeenCounter = new AtomicInteger(0) - for { - env <- Env.create( + "throttle message batches" in { + val counter = new AtomicInteger(0) + val maxSeenCounter = new AtomicInteger(0) + val maxSequencerCounter = new AtomicLong(0L) + val env = factory.create( options = SequencerClientConfig( eventInboxSize = PositiveInt.tryCreate(1), maximumInFlightEventBatches = PositiveInt.tryCreate(5), ), initialSequencerCounter = SequencerCounter(1L), ) - _ <- env.subscribeAfter( - CantonTimestamp.Epoch, - ApplicationHandler.create("test-handler-throttling") { e => - HandlerResult.asynchronous( - FutureUnlessShutdown.outcomeF(Future { - blocking { - maxSeenCounter.synchronized { - maxSeenCounter.set(Math.max(counter.incrementAndGet(), maxSeenCounter.get())) - } - } - Threading.sleep(100) - counter.decrementAndGet().discard - }(SequencerClientTest.this.executorService)) - ) - }, - ) - _ <- MonadUtil.sequentialTraverse_(1 to 100) { i => - env.transport.subscriber.value.sendToHandler(deliver(i.toLong)) + env + .subscribeAfter( + CantonTimestamp.Epoch, + ApplicationHandler.create("test-handler-throttling") { e => + val firstSc = e.value.head.counter + val lastSc = e.value.last.counter + logger.debug(s"Processing batch of events ${firstSc} to ${lastSc}") + HandlerResult.asynchronous( + FutureUnlessShutdown.outcomeF(Future { + blocking { + maxSeenCounter.synchronized { + maxSeenCounter.set(Math.max(counter.incrementAndGet(), maxSeenCounter.get())) + } + } + Threading.sleep(100) + counter.decrementAndGet().discard + maxSequencerCounter.updateAndGet(_ max lastSc.unwrap).discard + }(SequencerClientTest.this.executorService)) + ) + }, + ) + .futureValue + + for (i <- 1 to 100) { + env.transport.subscriber.value.sendToHandler(deliver(i.toLong)).futureValue } - } yield { + + eventually() { + maxSequencerCounter.get shouldBe 100 + } + maxSeenCounter.get() shouldBe 5 } } } - "subscribeTracking" should { - "updates sequencer counter prehead" in { - val preHeadF = for { - Env(client, transport, sequencerCounterTrackerStore, _, timeTracker) <- Env.create() + def richSequencerClient(): Unit = { + "subscribe" should { + "stores the event in the SequencedEventStore" in { + val env @ Env(client, transport, _, sequencedEventStore, _) = RichEnvFactory.create() + val storedEventF = for { + _ <- env.subscribeAfter() + _ <- transport.subscriber.value.sendToHandler(signedDeliver) + _ <- client.flush() + storedEvent <- sequencedEventStore.sequencedEvents() + } yield storedEvent - _ <- client.subscribeTracking( - sequencerCounterTrackerStore, - alwaysSuccessfulHandler, - timeTracker, - ) - _ <- transport.subscriber.value.handler(signedDeliver) - _ <- client.flushClean() - preHead <- sequencerCounterTrackerStore.preheadSequencerCounter - } yield preHead.value + storedEventF.futureValue shouldBe Seq(signedDeliver) + } - preHeadF.futureValue shouldBe CursorPrehead(deliver.counter, deliver.timestamp) - } + "stores the event even if the handler fails" in { + val env @ Env(client, transport, _, sequencedEventStore, _) = RichEnvFactory.create() + val storedEventF = for { + _ <- env.subscribeAfter(eventHandler = alwaysFailingHandler) + _ <- loggerFactory.assertLogs( + { + for { + _ <- transport.subscriber.value.sendToHandler(signedDeliver) + _ <- client.flush() + } yield () + }, + logEntry => { + logEntry.errorMessage should be( + "Synchronous event processing failed for event batch with sequencer counters 42 to 42." + ) + logEntry.throwable.value shouldBe failureException + }, + ) + storedEvent <- sequencedEventStore.sequencedEvents() + } yield storedEvent - "replays from the sequencer counter prehead" in { - val processedEvents = new ConcurrentLinkedQueue[SequencerCounter] + storedEventF.futureValue shouldBe Seq(signedDeliver) + } - val preheadF = for { - Env(client, _transport, sequencerCounterTrackerStore, _, timeTracker) <- Env.create( - storedEvents = Seq(deliver, nextDeliver, deliver44, deliver45), - cleanPrehead = Some(CursorPrehead(nextDeliver.counter, nextDeliver.timestamp)), - ) + "completes the sequencer client if the subscription closes due to an error" in { + val error = + EventValidationError(GapInSequencerCounter(SequencerCounter(666), SequencerCounter(0))) + val env @ Env(client, transport, _, _, _) = RichEnvFactory.create() + val closeReasonF = for { + _ <- env.subscribeAfter(CantonTimestamp.MinValue, alwaysSuccessfulHandler) + subscription = transport.subscriber + // we know the resilient sequencer subscription is using this type + .map(_.subscription.asInstanceOf[MockSubscription[SequencerClientSubscriptionError]]) + .value + closeReason <- loggerFactory.assertLogs( + { + subscription.closeSubscription(error) + client.completion + }, + _.warningMessage should include("sequencer"), + ) + } yield closeReason - _ <- client.subscribeTracking( - sequencerCounterTrackerStore, - ApplicationHandler.create("") { events => - events.value.foreach(event => processedEvents.add(event.counter)) - alwaysSuccessfulHandler(events) - }, - timeTracker, - ) - _ <- client.flushClean() - prehead <- sequencerCounterTrackerStore.preheadSequencerCounter - } yield prehead.value + closeReasonF.futureValue should matchPattern { + case e: UnrecoverableError if e.cause == s"handler returned error: $error" => + } + } - preheadF.futureValue shouldBe CursorPrehead(deliver45.counter, deliver45.timestamp) - processedEvents.iterator().asScala.toSeq shouldBe Seq( - deliver44.counter, - deliver45.counter, - ) + "completes the sequencer client if the application handler fails" in { + val error = new RuntimeException("failed handler") + val syncError = ApplicationHandlerException(error, deliver.counter, deliver.counter) + val handler: PossiblyIgnoredApplicationHandler[ClosedEnvelope] = + ApplicationHandler.create("async-failure")(_ => + FutureUnlessShutdown.failed[AsyncResult](error) + ) - } + val env @ Env(client, transport, _, _, _) = RichEnvFactory.create() + val closeReasonF = for { + _ <- env.subscribeAfter(CantonTimestamp.MinValue, handler) + closeReason <- loggerFactory.assertLogs( + { + for { + _ <- transport.subscriber.value.sendToHandler(deliver) + // Send the next event so that the client notices that an error has occurred. + _ <- client.flush() + _ <- transport.subscriber.value.sendToHandler(nextDeliver) + // wait until the subscription is closed (will emit an error) + closeReason <- client.completion + } yield closeReason + }, + logEntry => { + logEntry.errorMessage should be( + s"Synchronous event processing failed for event batch with sequencer counters ${deliver.counter} to ${deliver.counter}." + ) + logEntry.throwable shouldBe Some(error) + }, + _.warningMessage should include( + s"Closing resilient sequencer subscription due to error: HandlerError($syncError)" + ), + ) - "resubscribes after replay" in { - val processedEvents = new ConcurrentLinkedQueue[SequencerCounter] + } yield { + client.close() // make sure that we can still close the sequencer client + closeReason + } - val preheadF = for { - Env(client, transport, sequencerCounterTrackerStore, _, timeTracker) <- Env.create( - storedEvents = Seq(deliver, nextDeliver, deliver44), - cleanPrehead = Some(CursorPrehead(nextDeliver.counter, nextDeliver.timestamp)), - ) - _ <- client.subscribeTracking( - sequencerCounterTrackerStore, - ApplicationHandler.create("") { events => - events.value.foreach(event => processedEvents.add(event.counter)) - alwaysSuccessfulHandler(events) - }, - timeTracker, - ) - _ <- transport.subscriber.value.sendToHandler(deliver45) - _ <- client.flushClean() - prehead <- sequencerCounterTrackerStore.preheadSequencerCounter - } yield prehead.value + closeReasonF.futureValue should matchPattern { + case e: UnrecoverableError if e.cause == s"handler returned error: $syncError" => + } + } - preheadF.futureValue shouldBe CursorPrehead(deliver45.counter, deliver45.timestamp) + "completes the sequencer client if the application handler shuts down synchronously" in { + val handler: PossiblyIgnoredApplicationHandler[ClosedEnvelope] = + ApplicationHandler.create("shutdown")(_ => FutureUnlessShutdown.abortedDueToShutdown) - processedEvents.iterator().asScala.toSeq shouldBe Seq( - deliver44.counter, - deliver45.counter, - ) - } - - "does not update the prehead if the application handler fails" in { - val preHeadF = for { - Env(client, transport, sequencerCounterTrackerStore, _, timeTracker) <- Env.create() - - _ <- client.subscribeTracking( - sequencerCounterTrackerStore, - alwaysFailingHandler, - timeTracker, - ) - _ <- loggerFactory.assertLogs( - { + val env @ Env(client, transport, _, _, _) = RichEnvFactory.create() + val closeReasonF = for { + _ <- env.subscribeAfter(eventHandler = handler) + closeReason <- { for { - _ <- transport.subscriber.value.handler(signedDeliver) - _ <- client.flushClean() - } yield () - }, - logEntry => { - logEntry.errorMessage should be( - "Synchronous event processing failed for event batch with sequencer counters 42 to 42." - ) - logEntry.throwable.value shouldBe failureException - }, - ) - preHead <- sequencerCounterTrackerStore.preheadSequencerCounter - } yield preHead - - preHeadF.futureValue shouldBe None - } - - "updates the prehead only after the asynchronous processing has been completed" in { - val promises = Map[SequencerCounter, Promise[UnlessShutdown[Unit]]]( - nextDeliver.counter -> Promise[UnlessShutdown[Unit]](), - deliver44.counter -> Promise[UnlessShutdown[Unit]](), - ) - - def handler: PossiblyIgnoredApplicationHandler[ClosedEnvelope] = - ApplicationHandler.create("") { events => - assert(events.value.size == 1) - promises.get(events.value(0).counter) match { - case None => HandlerResult.done - case Some(promise) => HandlerResult.asynchronous(FutureUnlessShutdown(promise.future)) + _ <- transport.subscriber.value.sendToHandler(deliver) + // Send the next event so that the client notices that an error has occurred. + _ <- client.flush() + _ <- transport.subscriber.value.sendToHandler(nextDeliver) + closeReason <- client.completion + } yield closeReason } + } yield { + client.close() // make sure that we can still close the sequencer client + closeReason } - val testF = for { - Env(client, transport, sequencerCounterTrackerStore, _, timeTracker) <- Env.create( - options = SequencerClientConfig(eventInboxSize = PositiveInt.tryCreate(1)) - ) - _ <- client.subscribeTracking(sequencerCounterTrackerStore, handler, timeTracker) - _ <- transport.subscriber.value.sendToHandler(deliver) - _ <- client.flushClean() - prehead42 <- sequencerCounterTrackerStore.preheadSequencerCounter - _ <- transport.subscriber.value.sendToHandler(nextDeliver) - prehead43 <- sequencerCounterTrackerStore.preheadSequencerCounter - _ <- transport.subscriber.value.sendToHandler(deliver44) - _ = promises(deliver44.counter).success(UnlessShutdown.unit) - prehead43a <- sequencerCounterTrackerStore.preheadSequencerCounter - _ = promises(nextDeliver.counter).success( - UnlessShutdown.unit - ) // now we can advance the prehead - _ <- client.flushClean() - prehead44 <- sequencerCounterTrackerStore.preheadSequencerCounter - } yield { - prehead42 shouldBe Some(CursorPrehead(deliver.counter, deliver.timestamp)) - prehead43 shouldBe Some(CursorPrehead(deliver.counter, deliver.timestamp)) - prehead43a shouldBe Some(CursorPrehead(deliver.counter, deliver.timestamp)) - prehead44 shouldBe Some(CursorPrehead(deliver44.counter, deliver44.timestamp)) + closeReasonF.futureValue shouldBe ClientShutdown } - testF.futureValue + "completes the sequencer client if asynchronous event processing fails" in { + val error = new RuntimeException("asynchronous failure") + val asyncFailure = HandlerResult.asynchronous(FutureUnlessShutdown.failed(error)) + val asyncException = ApplicationHandlerException(error, deliver.counter, deliver.counter) + + val env @ Env(client, transport, _, _, _) = RichEnvFactory.create() + val closeReasonF = for { + _ <- env.subscribeAfter( + eventHandler = ApplicationHandler.create("async-failure")(_ => asyncFailure) + ) + closeReason <- loggerFactory.assertLogs( + { + for { + _ <- transport.subscriber.value.sendToHandler(deliver) + // Make sure that the asynchronous error has been noticed + // We intentionally do two flushes. The first captures `handleReceivedEventsUntilEmpty` completing. + // During this it may addToFlush a future for capturing `asyncSignalledF` however this may occur + // after we've called `flush` and therefore won't guarantee completing all processing. + // So our second flush will capture `asyncSignalledF` for sure. + _ <- client.flush() + _ <- client.flush() + // Send the next event so that the client notices that an error has occurred. + _ <- transport.subscriber.value.sendToHandler(nextDeliver) + _ <- client.flush() + // wait until client completed (will write an error) + closeReason <- client.completion + _ = client.close() // make sure that we can still close the sequencer client + } yield closeReason + }, + logEntry => { + logEntry.errorMessage should include( + s"Asynchronous event processing failed for event batch with sequencer counters ${deliver.counter} to ${deliver.counter}" + ) + logEntry.throwable shouldBe Some(error) + }, + _.warningMessage should include( + s"Closing resilient sequencer subscription due to error: HandlerError($asyncException)" + ), + ) + } yield closeReason + + closeReasonF.futureValue should matchPattern { + case e: UnrecoverableError if e.cause == s"handler returned error: $asyncException" => + } + } + + "completes the sequencer client if asynchronous event processing shuts down" in { + val asyncShutdown = HandlerResult.asynchronous(FutureUnlessShutdown.abortedDueToShutdown) + + val env @ Env(client, transport, _, _, _) = RichEnvFactory.create() + val closeReasonF = for { + _ <- env.subscribeAfter( + CantonTimestamp.MinValue, + ApplicationHandler.create("async-shutdown")(_ => asyncShutdown), + ) + closeReason <- { + for { + _ <- transport.subscriber.value.sendToHandler(deliver) + _ <- client.flushClean() // Make sure that the asynchronous error has been noticed + // Send the next event so that the client notices that an error has occurred. + _ <- transport.subscriber.value.sendToHandler(nextDeliver) + _ <- client.flush() + closeReason <- client.completion + } yield closeReason + } + } yield { + client.close() // make sure that we can still close the sequencer client + closeReason + } + + closeReasonF.futureValue shouldBe ClientShutdown + } + } + + "subscribeTracking" should { + "updates sequencer counter prehead" in { + val Env(client, transport, sequencerCounterTrackerStore, _, timeTracker) = + RichEnvFactory.create() + val preHeadF = for { + _ <- client.subscribeTracking( + sequencerCounterTrackerStore, + alwaysSuccessfulHandler, + timeTracker, + ) + _ <- transport.subscriber.value.sendToHandler(signedDeliver) + _ <- client.flushClean() + preHead <- sequencerCounterTrackerStore.preheadSequencerCounter + } yield preHead.value + + preHeadF.futureValue shouldBe CursorPrehead(deliver.counter, deliver.timestamp) + } + + "replays from the sequencer counter prehead" in { + val processedEvents = new ConcurrentLinkedQueue[SequencerCounter] + val Env(client, _transport, sequencerCounterTrackerStore, _, timeTracker) = + RichEnvFactory.create( + storedEvents = Seq(deliver, nextDeliver, deliver44, deliver45), + cleanPrehead = Some(CursorPrehead(nextDeliver.counter, nextDeliver.timestamp)), + ) + val preheadF = for { + _ <- client.subscribeTracking( + sequencerCounterTrackerStore, + ApplicationHandler.create("") { events => + events.value.foreach(event => processedEvents.add(event.counter)) + alwaysSuccessfulHandler(events) + }, + timeTracker, + ) + _ <- client.flushClean() + prehead <- sequencerCounterTrackerStore.preheadSequencerCounter + } yield prehead.value + + preheadF.futureValue shouldBe CursorPrehead(deliver45.counter, deliver45.timestamp) + processedEvents.iterator().asScala.toSeq shouldBe Seq( + deliver44.counter, + deliver45.counter, + ) + + } + + "resubscribes after replay" in { + val processedEvents = new ConcurrentLinkedQueue[SequencerCounter] + + val Env(client, transport, sequencerCounterTrackerStore, _, timeTracker) = + RichEnvFactory.create( + storedEvents = Seq(deliver, nextDeliver, deliver44), + cleanPrehead = Some(CursorPrehead(nextDeliver.counter, nextDeliver.timestamp)), + ) + val preheadF = for { + _ <- client.subscribeTracking( + sequencerCounterTrackerStore, + ApplicationHandler.create("") { events => + events.value.foreach(event => processedEvents.add(event.counter)) + alwaysSuccessfulHandler(events) + }, + timeTracker, + ) + _ <- transport.subscriber.value.sendToHandler(deliver45) + _ <- client.flushClean() + prehead <- sequencerCounterTrackerStore.preheadSequencerCounter + } yield prehead.value + + preheadF.futureValue shouldBe CursorPrehead(deliver45.counter, deliver45.timestamp) + + processedEvents.iterator().asScala.toSeq shouldBe Seq( + deliver44.counter, + deliver45.counter, + ) + } + + "does not update the prehead if the application handler fails" in { + val Env(client, transport, sequencerCounterTrackerStore, _, timeTracker) = + RichEnvFactory.create() + val preHeadF = for { + _ <- client.subscribeTracking( + sequencerCounterTrackerStore, + alwaysFailingHandler, + timeTracker, + ) + _ <- loggerFactory.assertLogs( + { + for { + _ <- transport.subscriber.value.sendToHandler(signedDeliver) + _ <- client.flushClean() + } yield () + }, + logEntry => { + logEntry.errorMessage should be( + "Synchronous event processing failed for event batch with sequencer counters 42 to 42." + ) + logEntry.throwable.value shouldBe failureException + }, + ) + preHead <- sequencerCounterTrackerStore.preheadSequencerCounter + } yield preHead + + preHeadF.futureValue shouldBe None + } + + "updates the prehead only after the asynchronous processing has been completed" in { + val promises = Map[SequencerCounter, Promise[UnlessShutdown[Unit]]]( + nextDeliver.counter -> Promise[UnlessShutdown[Unit]](), + deliver44.counter -> Promise[UnlessShutdown[Unit]](), + ) + + def handler: PossiblyIgnoredApplicationHandler[ClosedEnvelope] = + ApplicationHandler.create("") { events => + assert(events.value.size == 1) + promises.get(events.value(0).counter) match { + case None => HandlerResult.done + case Some(promise) => HandlerResult.asynchronous(FutureUnlessShutdown(promise.future)) + } + } + + val Env(client, transport, sequencerCounterTrackerStore, _, timeTracker) = + RichEnvFactory.create( + options = SequencerClientConfig(eventInboxSize = PositiveInt.tryCreate(1)) + ) + val testF = for { + _ <- client.subscribeTracking(sequencerCounterTrackerStore, handler, timeTracker) + _ <- transport.subscriber.value.sendToHandler(deliver) + _ <- client.flushClean() + prehead42 <- sequencerCounterTrackerStore.preheadSequencerCounter + _ <- transport.subscriber.value.sendToHandler(nextDeliver) + prehead43 <- sequencerCounterTrackerStore.preheadSequencerCounter + _ <- transport.subscriber.value.sendToHandler(deliver44) + _ = promises(deliver44.counter).success(UnlessShutdown.unit) + prehead43a <- sequencerCounterTrackerStore.preheadSequencerCounter + _ = promises(nextDeliver.counter).success( + UnlessShutdown.unit + ) // now we can advance the prehead + _ <- client.flushClean() + prehead44 <- sequencerCounterTrackerStore.preheadSequencerCounter + } yield { + prehead42 shouldBe Some(CursorPrehead(deliver.counter, deliver.timestamp)) + prehead43 shouldBe Some(CursorPrehead(deliver.counter, deliver.timestamp)) + prehead43a shouldBe Some(CursorPrehead(deliver.counter, deliver.timestamp)) + prehead44 shouldBe Some(CursorPrehead(deliver44.counter, deliver44.timestamp)) + } + + testF.futureValue + } + } + + "changeTransport" should { + "create second subscription from the same counter as the previous one when there are no events" in { + val secondTransport = MockTransport() + val env = RichEnvFactory.create(initialSequencerCounter = SequencerCounter.Genesis) + val testF = for { + _ <- env.subscribeAfter() + _ <- env.changeTransport(secondTransport) + } yield { + val originalSubscriber = env.transport.subscriber.value + originalSubscriber.request.counter shouldBe SequencerCounter.Genesis + originalSubscriber.subscription.isClosing shouldBe true // old subscription gets closed + env.transport.isClosing shouldBe true + + val newSubscriber = secondTransport.subscriber.value + newSubscriber.request.counter shouldBe SequencerCounter.Genesis + newSubscriber.subscription.isClosing shouldBe false + secondTransport.isClosing shouldBe false + + env.client.completion.isCompleted shouldBe false + } + + testF.futureValue + } + + "create second subscription from the same counter as the previous one when there are events" in { + val secondTransport = MockTransport() + + val env = RichEnvFactory.create() + val testF = for { + _ <- env.subscribeAfter() + + _ <- env.transport.subscriber.value.sendToHandler(deliver) + _ <- env.transport.subscriber.value.sendToHandler(nextDeliver) + _ <- env.client.flushClean() + + _ <- env.changeTransport(secondTransport) + } yield { + val originalSubscriber = env.transport.subscriber.value + originalSubscriber.request.counter shouldBe firstSequencerCounter + + val newSubscriber = secondTransport.subscriber.value + newSubscriber.request.counter shouldBe nextDeliver.counter + + env.client.completion.isCompleted shouldBe false + } + + testF.futureValue + } + + "have new transport be used for sends" in { + val secondTransport = MockTransport() + + val env = RichEnvFactory.create() + val testF = for { + _ <- env.changeTransport(secondTransport) + _ <- env.sendAsync(Batch.empty(testedProtocolVersion)) + } yield { + env.transport.lastSend.get() shouldBe None + secondTransport.lastSend.get() should not be None + + env.transport.isClosing shouldBe true + secondTransport.isClosing shouldBe false + } + + testF.futureValue + } + + "have new transport be used for sends when there is subscription" in { + val secondTransport = MockTransport() + + val env = RichEnvFactory.create() + val testF = for { + _ <- env.subscribeAfter() + _ <- env.changeTransport(secondTransport) + _ <- env.sendAsync(Batch.empty(testedProtocolVersion)) + } yield { + env.transport.lastSend.get() shouldBe None + secondTransport.lastSend.get() should not be None + } + + testF.futureValue + } + + "have new transport be used with same sequencerId but different sequencer alias" in { + val secondTransport = MockTransport() + + val env = RichEnvFactory.create() + val testF = for { + _ <- env.subscribeAfter() + _ <- env.changeTransport( + SequencerTransports.single( + SequencerAlias.tryCreate("somethingElse"), + sequencerId, + secondTransport, + ) + ) + _ <- env.sendAsync(Batch.empty(testedProtocolVersion)) + } yield { + env.transport.lastSend.get() shouldBe None + secondTransport.lastSend.get() should not be None + } + + testF.futureValue + } + + "fail to reassign sequencerId" in { + val secondTransport = MockTransport() + val secondSequencerId = SequencerId( + UniqueIdentifier(Identifier.tryCreate("da2"), Namespace(Fingerprint.tryCreate("default"))) + ) + + val env = RichEnvFactory.create() + val testF = for { + _ <- env.subscribeAfter() + error <- loggerFactory + .assertLogs( + env + .changeTransport( + SequencerTransports.default( + secondSequencerId, + secondTransport, + ) + ), + _.errorMessage shouldBe "Adding or removing sequencer subscriptions is not supported at the moment", + ) + .failed + } yield { + error + } + + testF.futureValue shouldBe an[IllegalArgumentException] + testF.futureValue.getMessage shouldBe "Adding or removing sequencer subscriptions is not supported at the moment" + } } } - "changeTransport" should { - "create second subscription from the same counter as the previous one when there are no events" in { - val secondTransport = MockTransport() - val testF = for { - env <- Env.create(initialSequencerCounter = SequencerCounter.Genesis) - _ <- env.subscribeAfter() - _ <- env.changeTransport(secondTransport) - } yield { - val originalSubscriber = env.transport.subscriber.value - originalSubscriber.request.counter shouldBe SequencerCounter.Genesis - originalSubscriber.subscription.isClosing shouldBe true // old subscription gets closed - env.transport.isClosing shouldBe true - - val newSubscriber = secondTransport.subscriber.value - newSubscriber.request.counter shouldBe SequencerCounter.Genesis - newSubscriber.subscription.isClosing shouldBe false - secondTransport.isClosing shouldBe false - - env.client.completion.isCompleted shouldBe false - } - - testF.futureValue - } - - "create second subscription from the same counter as the previous one when there are events" in { - val secondTransport = MockTransport() - - val testF = for { - env <- Env.create() - _ <- env.subscribeAfter() - - _ <- env.transport.subscriber.value.sendToHandler(deliver) - _ <- env.transport.subscriber.value.sendToHandler(nextDeliver) - _ <- env.client.flushClean() - - _ <- env.changeTransport(secondTransport) - } yield { - val originalSubscriber = env.transport.subscriber.value - originalSubscriber.request.counter shouldBe firstSequencerCounter - - val newSubscriber = secondTransport.subscriber.value - newSubscriber.request.counter shouldBe nextDeliver.counter - - env.client.completion.isCompleted shouldBe false - } - - testF.futureValue - } - - "have new transport be used for sends" in { - val secondTransport = MockTransport() - - val testF = for { - env <- Env.create() - _ <- env.changeTransport(secondTransport) - _ <- env.sendAsync(Batch.empty(testedProtocolVersion)) - } yield { - env.transport.lastSend.get() shouldBe None - secondTransport.lastSend.get() should not be None - - env.transport.isClosing shouldBe true - secondTransport.isClosing shouldBe false - } - - testF.futureValue - } - - "have new transport be used for sends when there is subscription" in { - val secondTransport = MockTransport() - - val testF = for { - env <- Env.create() - _ <- env.subscribeAfter() - _ <- env.changeTransport(secondTransport) - _ <- env.sendAsync(Batch.empty(testedProtocolVersion)) - } yield { - env.transport.lastSend.get() shouldBe None - secondTransport.lastSend.get() should not be None - } - - testF.futureValue - } - - "have new transport be used with same sequencerId but different sequencer alias" in { - val secondTransport = MockTransport() - - val testF = for { - env <- Env.create() - _ <- env.subscribeAfter() - _ <- env.changeTransport( - SequencerTransports.single( - SequencerAlias.tryCreate("somethingElse"), - sequencerId, - secondTransport, - ) - ) - _ <- env.sendAsync(Batch.empty(testedProtocolVersion)) - } yield { - env.transport.lastSend.get() shouldBe None - secondTransport.lastSend.get() should not be None - } - - testF.futureValue - } - - "fail to reassign sequencerId" in { - val secondTransport = MockTransport() - val secondSequencerId = SequencerId( - UniqueIdentifier(Identifier.tryCreate("da2"), Namespace(Fingerprint.tryCreate("default"))) - ) - - val testF = for { - env <- Env.create() - _ <- env.subscribeAfter() - error <- loggerFactory - .assertLogs( - env - .changeTransport( - SequencerTransports.default( - secondSequencerId, - secondTransport, - ) - ), - _.errorMessage shouldBe "Adding or removing sequencer subscriptions is not supported at the moment", - ) - .failed - } yield { - error - } - - testF.futureValue shouldBe an[IllegalArgumentException] - testF.futureValue.getMessage shouldBe "Adding or removing sequencer subscriptions is not supported at the moment" - } + "RichSequencerClientImpl" should { + behave like sequencerClient(RichEnvFactory) + behave like richSequencerClient() } - private case class Subscriber[E]( - request: SubscriptionRequest, - handler: SerializedEventHandler[E], - subscription: MockSubscription[E], - ) { + "SequencerClientImplPekko" should { + behave like sequencerClient(PekkoEnvFactory) + } + + private sealed trait Subscriber[E] { + def request: SubscriptionRequest + def subscription: MockSubscription[E] + def sendToHandler(event: OrdinarySerializedEvent): Future[Unit] + def sendToHandler(event: SequencedEvent[ClosedEnvelope]): Future[Unit] = { - handler(OrdinarySequencedEvent(SequencerTestUtils.sign(event), None)(traceContext)) - .transform { - case Success(Right(_)) => Success(()) - case Success(Left(err)) => - subscription.closeSubscription(err) - Success(()) - case Failure(ex) => - subscription.closeSubscription(ex) - Success(()) - } + sendToHandler(OrdinarySequencedEvent(SequencerTestUtils.sign(event), None)(traceContext)) } } - private case class Env( - client: SequencerClientImpl, + private case class OldStyleSubscriber[E]( + override val request: SubscriptionRequest, + private val handler: SerializedEventHandler[E], + override val subscription: MockSubscription[E], + ) extends Subscriber[E] { + override def sendToHandler(event: OrdinarySerializedEvent): Future[Unit] = + handler(event).transform { + case Success(Right(_)) => Success(()) + case Success(Left(err)) => + subscription.closeSubscription(err) + Success(()) + case Failure(ex) => + subscription.closeSubscription(ex) + Success(()) + } + } + + private case class SubscriberPekko[E]( + override val request: SubscriptionRequest, + private val queue: BoundedSourceQueue[OrdinarySerializedEvent], + override val subscription: MockSubscription[E], + ) extends Subscriber[E] { + override def sendToHandler(event: OrdinarySerializedEvent): Future[Unit] = { + queue.offer(event) match { + case QueueOfferResult.Enqueued => + // TODO(#13789) This may need more synchronization + Future.unit + case QueueOfferResult.Failure(ex) => + logger.error(s"Failed to enqueue event", ex) + fail("Failed to enqueue event") + case other => + fail(s"Could not enqueue event $event: $other") + } + } + } + + private case class Env[+Client <: SequencerClient]( + client: Client, transport: MockTransport, sequencerCounterTrackerStore: SequencerCounterTrackerStore, sequencedEventStore: SequencedEventStore, @@ -844,14 +910,16 @@ class SequencerClientTest def changeTransport( newTransport: SequencerClientTransport & SequencerClientTransportPekko - ): Future[Unit] = { - client.changeTransport( + )(implicit ev: Client <:< RichSequencerClient): Future[Unit] = { + changeTransport( SequencerTransports.default(sequencerId, newTransport) ) } - def changeTransport(sequencerTransports: SequencerTransports[?]): Future[Unit] = - client.changeTransport(sequencerTransports) + def changeTransport(sequencerTransports: SequencerTransports[?])(implicit + ev: Client <:< RichSequencerClient + ): Future[Unit] = + ev(client).changeTransport(sequencerTransports) def sendAsync( batch: Batch[DefaultOpenEnvelope] @@ -903,7 +971,7 @@ class SequencerClientTest case None => None } - subscriber(retry = 10) + subscriber(retry = 100) } val lastSend = new AtomicReference[Option[SubmissionRequest]](None) @@ -943,7 +1011,9 @@ class SequencerClientTest ): SequencerSubscription[E] = { val subscription = new MockSubscription[E] - if (!subscriberRef.compareAndSet(None, Some(Subscriber(request, handler, subscription)))) { + if ( + !subscriberRef.compareAndSet(None, Some(OldStyleSubscriber(request, handler, subscription))) + ) { fail("subscribe has already been called by this client") } @@ -973,8 +1043,24 @@ class SequencerClientTest override def subscribe(request: SubscriptionRequest)(implicit traceContext: TraceContext - ): SequencerSubscriptionPekko[SubscriptionError] = - ??? // TODO(#13789) implement this + ): SequencerSubscriptionPekko[SubscriptionError] = { + // Choose a sufficiently large queue size so that we can test throttling + val (queue, sourceQueue) = + Source.queue[OrdinarySerializedEvent](200).preMaterialize()(materializer) + + val subscriber = SubscriberPekko(request, queue, new MockSubscription[Uninhabited]()) + subscriberRef.set(Some(subscriber)) + + val source = sourceQueue + .map(Either.right) + .withUniqueKillSwitchMat()(Keep.right) + .watchTermination()(Keep.both) + + SequencerSubscriptionPekko( + source, + new AlwaysHealthyComponent("sequencer-client-test-source", logger), + ) + } override def subscribeUnauthenticated(request: SubscriptionRequest)(implicit traceContext: TraceContext @@ -984,12 +1070,12 @@ class SequencerClientTest : SubscriptionErrorRetryPolicyPekko[SubscriptionError] = SubscriptionErrorRetryPolicyPekko.never } + private object MockTransport { - def apply(): MockTransport & SequencerClientTransportPekko.Aux[Uninhabited] = - new MockTransport + def apply(): MockTransport & SequencerClientTransportPekko.Aux[Uninhabited] = new MockTransport } - private implicit class RichSequencerClient(client: SequencerClientImpl) { + private implicit class EnrichedSequencerClient(client: RichSequencerClient) { // flush needs to be called twice in order to finish asynchronous processing // (see comment around shutdown in SequencerClient). So we have this small // helper for the tests. @@ -999,44 +1085,40 @@ class SequencerClientTest } yield () } - private object Env { - val eventAlwaysValid: SequencedEventValidator = SequencedEventValidator.noValidation( - DefaultTestIdentities.domainId, - warn = false, - ) + private val eventAlwaysValid: SequencedEventValidator = SequencedEventValidator.noValidation( + DefaultTestIdentities.domainId, + warn = false, + ) + private trait EnvFactory[+Client <: SequencerClient] { def create( storedEvents: Seq[SequencedEvent[ClosedEnvelope]] = Seq.empty, cleanPrehead: Option[SequencerCounterCursorPrehead] = None, eventValidator: SequencedEventValidator = eventAlwaysValid, options: SequencerClientConfig = SequencerClientConfig(), initialSequencerCounter: SequencerCounter = firstSequencerCounter, - )(implicit closeContext: CloseContext): Future[Env] = { - val clock = new SimClock(loggerFactory = loggerFactory) - val timeouts = DefaultProcessingTimeouts.testing - val transport = MockTransport() - val sendTrackerStore = new InMemorySendTrackerStore() - val sequencedEventStore = new InMemorySequencedEventStore(loggerFactory) - val sendTracker = - new SendTracker(Map.empty, sendTrackerStore, metrics, loggerFactory, timeouts) - val sequencerCounterTrackerStore = - new InMemorySequencerCounterTrackerStore(loggerFactory, timeouts) - val timeTracker = - new DomainTimeTracker( - DomainTimeTrackerConfig(), - clock, - new MockTimeRequestSubmitter(), - timeouts, - loggerFactory, - ) - val domainParameters = BaseTest.defaultStaticDomainParameters + )(implicit closeContext: CloseContext): Env[Client] - val eventValidatorFactory = new SequencedEventValidatorFactory { - override def create( - unauthenticated: Boolean - )(implicit loggingContext: NamedLoggingContext): SequencedEventValidator = - eventValidator - } + protected def preloadStores( + storedEvents: Seq[SequencedEvent[ClosedEnvelope]], + cleanPrehead: Option[SequencerCounterCursorPrehead], + sequencedEventStore: SequencedEventStore, + sequencerCounterTrackerStore: SequencerCounterTrackerStore, + ): Unit = { + val signedEvents = storedEvents.map(SequencerTestUtils.sign) + val preloadStores = for { + _ <- sequencedEventStore.store( + signedEvents.map(OrdinarySequencedEvent(_, None)(TraceContext.empty)) + ) + _ <- cleanPrehead.traverse_(prehead => + sequencerCounterTrackerStore.advancePreheadSequencerCounterTo(prehead) + ) + } yield () + preloadStores.futureValue + } + + protected def maxRequestSizeLookup + : DynamicDomainParametersLookup[DomainParametersLookup.SequencerDomainParameters] = { val topologyClient = mock[DomainTopologyClient] val mockTopologySnapshot = mock[TopologySnapshot] when(topologyClient.currentSnapshotApproximation(any[TraceContext])) @@ -1047,45 +1129,82 @@ class SequencerClientTest anyBoolean, )(any[TraceContext]) ) - .thenReturn( - Future.successful(TestDomainParameters.defaultDynamic) - ) - val maxRequestSizeLookup = - DomainParametersLookup.forSequencerDomainParameters( - domainParameters, - None, - topologyClient, - FutureSupervisor.Noop, - loggerFactory, - ) + .thenReturn(Future.successful(TestDomainParameters.defaultDynamic)) + DomainParametersLookup.forSequencerDomainParameters( + BaseTest.defaultStaticDomainParameters, + None, + topologyClient, + FutureSupervisor.Noop, + loggerFactory, + ) + } - val client = new SequencerClientImpl( + } + + private object MockRequestSigner extends RequestSigner { + override def signRequest[A <: HasCryptographicEvidence]( + request: A, + hashPurpose: HashPurpose, + )(implicit + ec: ExecutionContext, + traceContext: TraceContext, + ): EitherT[Future, String, SignedContent[A]] = { + val signedContent = SignedContent( + request, + SymbolicCrypto.emptySignature, + None, + testedProtocolVersion, + ) + EitherT(Future.successful(Either.right[String, SignedContent[A]](signedContent))) + } + } + + private class ConstantSequencedEventValidatorFactory(eventValidator: SequencedEventValidator) + extends SequencedEventValidatorFactory { + override def create( + unauthenticated: Boolean + )(implicit loggingContext: NamedLoggingContext): SequencedEventValidator = + eventValidator + } + + private object RichEnvFactory extends EnvFactory[RichSequencerClient] { + override def create( + storedEvents: Seq[SequencedEvent[ClosedEnvelope]], + cleanPrehead: Option[SequencerCounterCursorPrehead], + eventValidator: SequencedEventValidator, + options: SequencerClientConfig, + initialSequencerCounter: SequencerCounter, + )(implicit closeContext: CloseContext): Env[RichSequencerClient] = { + val clock = new SimClock(loggerFactory = loggerFactory) + val timeouts = DefaultProcessingTimeouts.testing + val transport = MockTransport() + val sendTrackerStore = new InMemorySendTrackerStore() + val sequencedEventStore = new InMemorySequencedEventStore(loggerFactory) + val sendTracker = + new SendTracker(Map.empty, sendTrackerStore, metrics, loggerFactory, timeouts) + val sequencerCounterTrackerStore = + new InMemorySequencerCounterTrackerStore(loggerFactory, timeouts) + val timeTracker = new DomainTimeTracker( + DomainTimeTrackerConfig(), + clock, + new MockTimeRequestSubmitter(), + timeouts, + loggerFactory, + ) + val eventValidatorFactory = new ConstantSequencedEventValidatorFactory(eventValidator) + + val client = new RichSequencerClientImpl( DefaultTestIdentities.domainId, participant1, SequencerTransports.default(DefaultTestIdentities.sequencerId, transport), options, TestingConfigInternal(), - domainParameters.protocolVersion, + BaseTest.defaultStaticDomainParameters.protocolVersion, maxRequestSizeLookup, timeouts, eventValidatorFactory, clock, - new RequestSigner { - override def signRequest[A <: HasCryptographicEvidence]( - request: A, - hashPurpose: HashPurpose, - )(implicit - ec: ExecutionContext, - traceContext: TraceContext, - ): EitherT[Future, String, SignedContent[A]] = - EitherT( - Future.successful( - Either.right[String, SignedContent[A]]( - SignedContent(request, SymbolicCrypto.emptySignature, None, testedProtocolVersion) - ) - ) - ) - }, + MockRequestSigner, sequencedEventStore, sendTracker, CommonMockMetrics.sequencerClient, @@ -1097,16 +1216,66 @@ class SequencerClientTest futureSupervisor, initialSequencerCounter, )(parallelExecutionContext, tracer) - val signedEvents = storedEvents.map(SequencerTestUtils.sign) - for { - _ <- sequencedEventStore.store( - signedEvents.map(OrdinarySequencedEvent(_, None)(TraceContext.empty)) - ) - _ <- cleanPrehead.traverse_(prehead => - sequencerCounterTrackerStore.advancePreheadSequencerCounterTo(prehead) - ) - } yield Env(client, transport, sequencerCounterTrackerStore, sequencedEventStore, timeTracker) + preloadStores(storedEvents, cleanPrehead, sequencedEventStore, sequencerCounterTrackerStore) + + Env(client, transport, sequencerCounterTrackerStore, sequencedEventStore, timeTracker) + } + } + + private object PekkoEnvFactory extends EnvFactory[SequencerClient] { + override def create( + storedEvents: Seq[SequencedEvent[ClosedEnvelope]], + cleanPrehead: Option[SequencerCounterCursorPrehead], + eventValidator: SequencedEventValidator, + options: SequencerClientConfig, + initialSequencerCounter: SequencerCounter, + )(implicit closeContext: CloseContext): Env[SequencerClient] = { + val clock = new SimClock(loggerFactory = loggerFactory) + val timeouts = DefaultProcessingTimeouts.testing + val transport = MockTransport() + val sendTrackerStore = new InMemorySendTrackerStore() + val sequencedEventStore = new InMemorySequencedEventStore(loggerFactory) + val sendTracker = + new SendTracker(Map.empty, sendTrackerStore, metrics, loggerFactory, timeouts) + val sequencerCounterTrackerStore = + new InMemorySequencerCounterTrackerStore(loggerFactory, timeouts) + val timeTracker = new DomainTimeTracker( + DomainTimeTrackerConfig(), + clock, + new MockTimeRequestSubmitter(), + timeouts, + loggerFactory, + ) + val eventValidatorFactory = new ConstantSequencedEventValidatorFactory(eventValidator) + + val client = new SequencerClientImplPekko( + DefaultTestIdentities.domainId, + participant1, + SequencerTransports.default(DefaultTestIdentities.sequencerId, transport), + options, + TestingConfigInternal(), + BaseTest.defaultStaticDomainParameters.protocolVersion, + maxRequestSizeLookup, + timeouts, + eventValidatorFactory, + clock, + MockRequestSigner, + sequencedEventStore, + sendTracker, + CommonMockMetrics.sequencerClient, + None, + false, + mock[CryptoPureApi], + LoggingConfig(), + loggerFactory, + futureSupervisor, + initialSequencerCounter, + )(PrettyInstances.prettyUninhabited, parallelExecutionContext, tracer, materializer) + + preloadStores(storedEvents, cleanPrehead, sequencedEventStore, sequencerCounterTrackerStore) + + Env(client, transport, sequencerCounterTrackerStore, sequencedEventStore, timeTracker) } } } diff --git a/canton-3x/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/indexer/parallel/BatchNSpec.scala b/canton-3x/community/common/src/test/scala/com/digitalasset/canton/util/BatchNSpec.scala similarity index 91% rename from canton-3x/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/indexer/parallel/BatchNSpec.scala rename to canton-3x/community/common/src/test/scala/com/digitalasset/canton/util/BatchNSpec.scala index 7b39b93657..97a9f836e4 100644 --- a/canton-3x/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/indexer/parallel/BatchNSpec.scala +++ b/canton-3x/community/common/src/test/scala/com/digitalasset/canton/util/BatchNSpec.scala @@ -1,9 +1,10 @@ // Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -package com.digitalasset.canton.platform.indexer.parallel +package com.digitalasset.canton.util import com.daml.ledger.api.testing.utils.PekkoBeforeAndAfterAll +import com.digitalasset.canton.util.PekkoUtil.syntax.* import org.apache.pekko.stream.Attributes.InputBuffer import org.apache.pekko.stream.scaladsl.{Sink, Source} import org.apache.pekko.stream.{Attributes, DelayOverflowStrategy} @@ -25,7 +26,7 @@ class BatchNSpec extends AsyncFlatSpec with Matchers with PekkoBeforeAndAfterAll Source(input).async // slow upstream .delay(10.millis, DelayOverflowStrategy.backpressure) - .via(BatchN(MaxBatchSize, MaxBatchCount)) + .batchN(MaxBatchSize, MaxBatchCount) .runWith(Sink.seq[Iterable[Int]]) batchesF.map { batches => @@ -40,7 +41,7 @@ class BatchNSpec extends AsyncFlatSpec with Matchers with PekkoBeforeAndAfterAll val batchesF = Source(input) - .via(BatchN(MaxBatchSize, MaxBatchCount)) + .batchN(MaxBatchSize, MaxBatchCount) // slow downstream .initialDelay(10.millis) .async @@ -62,7 +63,7 @@ class BatchNSpec extends AsyncFlatSpec with Matchers with PekkoBeforeAndAfterAll val batchesF = Source(input) - .via(BatchN(MaxBatchSize, MaxBatchCount)) + .batchN(MaxBatchSize, MaxBatchCount) // slow downstream .initialDelay(10.millis) .async @@ -84,7 +85,7 @@ class BatchNSpec extends AsyncFlatSpec with Matchers with PekkoBeforeAndAfterAll val batchesF = Source(input) - .via(BatchN(MaxBatchSize, MaxBatchCount)) + .batchN(MaxBatchSize, MaxBatchCount) // slow downstream .initialDelay(10.millis) .async diff --git a/canton-3x/community/common/src/test/scala/com/digitalasset/canton/util/OrderedBucketMergeHubTest.scala b/canton-3x/community/common/src/test/scala/com/digitalasset/canton/util/OrderedBucketMergeHubTest.scala index 8517d3b371..4458fb5264 100644 --- a/canton-3x/community/common/src/test/scala/com/digitalasset/canton/util/OrderedBucketMergeHubTest.scala +++ b/canton-3x/community/common/src/test/scala/com/digitalasset/canton/util/OrderedBucketMergeHubTest.scala @@ -13,6 +13,7 @@ import com.digitalasset.canton.util.OrderedBucketMergeHub.{ Output, OutputElement, } +import com.digitalasset.canton.util.PekkoUtil.noOpKillSwitch import com.digitalasset.canton.{BaseTest, DiscardOps} import org.apache.pekko.Done import org.apache.pekko.stream.QueueOfferResult.Enqueued @@ -30,8 +31,6 @@ import scala.concurrent.duration.DurationInt import scala.concurrent.{ExecutionContext, Future, Promise} class OrderedBucketMergeHubTest extends StreamSpec with BaseTest { - import PekkoUtilTest.* - // Override the implicit from PekkoSpec so that we don't get ambiguous implicits override val patience: PatienceConfig = defaultPatience diff --git a/canton-3x/community/common/src/test/scala/com/digitalasset/canton/util/PekkoUtilTest.scala b/canton-3x/community/common/src/test/scala/com/digitalasset/canton/util/PekkoUtilTest.scala index 59e69a0c0f..e3ba00d2de 100644 --- a/canton-3x/community/common/src/test/scala/com/digitalasset/canton/util/PekkoUtilTest.scala +++ b/canton-3x/community/common/src/test/scala/com/digitalasset/canton/util/PekkoUtilTest.scala @@ -11,7 +11,11 @@ import com.digitalasset.canton.config.RequireTypes.NonNegativeInt import com.digitalasset.canton.lifecycle.UnlessShutdown.{AbortedDueToShutdown, Outcome} import com.digitalasset.canton.lifecycle.{FutureUnlessShutdown, UnlessShutdown} import com.digitalasset.canton.util.PekkoUtil.syntax.* -import com.digitalasset.canton.util.PekkoUtil.{ContextualizedFlowOps, WithKillSwitch} +import com.digitalasset.canton.util.PekkoUtil.{ + ContextualizedFlowOps, + WithKillSwitch, + noOpKillSwitch, +} import com.digitalasset.canton.{BaseTestWordSpec, DiscardOps} import org.apache.pekko.stream.scaladsl.{Flow, Keep, Sink, Source} import org.apache.pekko.stream.testkit.StreamSpec @@ -834,6 +838,18 @@ class PekkoUtilTest extends StreamSpec with BaseTestWordSpec { } } + "dropIf" should { + "drop only elements that satisfy the condition" in assertAllStagesStopped { + val elemF = Source(1 to 10).dropIf(3)(_ % 2 == 0).toMat(Sink.seq)(Keep.right).run() + elemF.futureValue shouldBe Seq(1, 3, 5, 7, 8, 9, 10) + } + + "ignore negative counts" in assertAllStagesStopped { + val elemF = Source(1 to 10).dropIf(-1)(_ => false).toMat(Sink.seq)(Keep.right).run() + elemF.futureValue shouldBe (1 to 10) + } + } + "statefulMapAsyncContextualizedUS" should { "work with singleton contexts" in assertAllStagesStopped { val sinkF = Source(Seq(None, Some(2), Some(4))).contextualize @@ -912,11 +928,6 @@ class PekkoUtilTest extends StreamSpec with BaseTestWordSpec { } object PekkoUtilTest { - val noOpKillSwitch = new KillSwitch { - override def shutdown(): Unit = () - override def abort(ex: Throwable): Unit = () - } - def withNoOpKillSwitch[A](value: A): WithKillSwitch[A] = WithKillSwitch(value)(noOpKillSwitch) implicit val eqKillSwitch: Eq[KillSwitch] = Eq.fromUniversalEquals[KillSwitch] diff --git a/canton-3x/community/demo/src/main/daml/ai-analysis/daml.yaml b/canton-3x/community/demo/src/main/daml/ai-analysis/daml.yaml index 8530ebf70a..d0c3dbc060 100644 --- a/canton-3x/community/demo/src/main/daml/ai-analysis/daml.yaml +++ b/canton-3x/community/demo/src/main/daml/ai-analysis/daml.yaml @@ -1,11 +1,11 @@ -sdk-version: 2.9.0-snapshot.20231215.12512.0.v5a0f0a18 -build-options: - - --target=2.1 +sdk-version: 2.9.0-snapshot.20231231.12528.0.vca9bd5e6 +build-options: +- --target=2.1 name: ai-analysis source: AIAnalysis.daml init-script: AIAnalysis:setup version: 3.0.0 -dependencies: +dependencies: - daml-prim - daml-stdlib - daml3-script diff --git a/canton-3x/community/demo/src/main/daml/bank/daml.yaml b/canton-3x/community/demo/src/main/daml/bank/daml.yaml index a5132607ef..e3e5778491 100644 --- a/canton-3x/community/demo/src/main/daml/bank/daml.yaml +++ b/canton-3x/community/demo/src/main/daml/bank/daml.yaml @@ -1,11 +1,11 @@ -sdk-version: 2.9.0-snapshot.20231215.12512.0.v5a0f0a18 -build-options: - - --target=2.1 +sdk-version: 2.9.0-snapshot.20231231.12528.0.vca9bd5e6 +build-options: +- --target=2.1 name: bank source: Bank.daml init-script: Bank:setup version: 3.0.0 -dependencies: +dependencies: - daml-prim - daml-stdlib - daml3-script diff --git a/canton-3x/community/demo/src/main/daml/doctor/daml.yaml b/canton-3x/community/demo/src/main/daml/doctor/daml.yaml index 24943f320a..a37157d868 100644 --- a/canton-3x/community/demo/src/main/daml/doctor/daml.yaml +++ b/canton-3x/community/demo/src/main/daml/doctor/daml.yaml @@ -1,11 +1,11 @@ -sdk-version: 2.9.0-snapshot.20231215.12512.0.v5a0f0a18 -build-options: - - --target=2.1 +sdk-version: 2.9.0-snapshot.20231231.12528.0.vca9bd5e6 +build-options: +- --target=2.1 name: doctor source: Doctor.daml init-script: Doctor:setup version: 3.0.0 -dependencies: +dependencies: - daml-prim - daml-stdlib - daml3-script diff --git a/canton-3x/community/demo/src/main/daml/health-insurance/daml.yaml b/canton-3x/community/demo/src/main/daml/health-insurance/daml.yaml index be0ea9697f..3847c8f129 100644 --- a/canton-3x/community/demo/src/main/daml/health-insurance/daml.yaml +++ b/canton-3x/community/demo/src/main/daml/health-insurance/daml.yaml @@ -1,11 +1,11 @@ -sdk-version: 2.9.0-snapshot.20231215.12512.0.v5a0f0a18 -build-options: - - --target=2.1 +sdk-version: 2.9.0-snapshot.20231231.12528.0.vca9bd5e6 +build-options: +- --target=2.1 name: health-insurance source: HealthInsurance.daml init-script: HealthInsurance:setup version: 3.0.0 -dependencies: +dependencies: - daml-prim - daml-stdlib - daml3-script diff --git a/canton-3x/community/demo/src/main/daml/medical-records/daml.yaml b/canton-3x/community/demo/src/main/daml/medical-records/daml.yaml index aeff10a199..027ba16d95 100644 --- a/canton-3x/community/demo/src/main/daml/medical-records/daml.yaml +++ b/canton-3x/community/demo/src/main/daml/medical-records/daml.yaml @@ -1,11 +1,11 @@ -sdk-version: 2.9.0-snapshot.20231215.12512.0.v5a0f0a18 -build-options: - - --target=2.1 +sdk-version: 2.9.0-snapshot.20231231.12528.0.vca9bd5e6 +build-options: +- --target=2.1 name: medical-records source: MedicalRecord.daml init-script: MedicalRecord:setup version: 3.0.0 -dependencies: +dependencies: - daml-prim - daml-stdlib - daml3-script diff --git a/canton-3x/community/domain/src/main/protobuf/buf.yaml b/canton-3x/community/domain/src/main/protobuf/buf.yaml index 40cb626c4b..7b22a1863f 100644 --- a/canton-3x/community/domain/src/main/protobuf/buf.yaml +++ b/canton-3x/community/domain/src/main/protobuf/buf.yaml @@ -1,4 +1,5 @@ version: v1 build: excludes: - - com/digitalasset/canton/domain/scalapb \ No newline at end of file + - com/digitalasset/canton/domain/scalapb + - com/digitalasset/canton/domain/admin/ diff --git a/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/domain_initialization_service.proto b/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/domain_initialization_service.proto index cf5c60aa96..80562c2def 100644 --- a/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/domain_initialization_service.proto +++ b/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/domain_initialization_service.proto @@ -5,7 +5,7 @@ syntax = "proto3"; package com.digitalasset.canton.domain.admin.v0; -import "com/digitalasset/canton/domain/api/v0/sequencer_connection.proto"; +import "com/digitalasset/canton/admin/domain/v0/sequencer_connection.proto"; import "google/protobuf/empty.proto"; // TODO(#15223) rename to DomainManagerInitializationService @@ -18,7 +18,7 @@ service DomainInitializationService { message DomainNodeSequencerConfig { // connection information to sequencer - com.digitalasset.canton.domain.api.v0.SequencerConnection sequencerConnection = 1; + com.digitalasset.canton.admin.domain.v0.SequencerConnection sequencerConnection = 1; } message DomainInitRequest { diff --git a/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/enterprise_mediator_administration_service.proto b/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/enterprise_mediator_administration_service.proto index 146ee180b3..716939e4f4 100644 --- a/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/enterprise_mediator_administration_service.proto +++ b/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/enterprise_mediator_administration_service.proto @@ -5,7 +5,7 @@ syntax = "proto3"; package com.digitalasset.canton.domain.admin.v0; -import "com/digitalasset/canton/pruning/admin/v0/pruning.proto"; +import "com/digitalasset/canton/admin/pruning/v0/pruning.proto"; import "google/protobuf/empty.proto"; import "google/protobuf/timestamp.proto"; @@ -20,22 +20,22 @@ service EnterpriseMediatorAdministrationService { // or duration. // - ``FAILED_PRECONDITION``: if automatic background pruning has not been enabled // or if invoked on a participant running the Community Edition. - rpc SetSchedule(com.digitalasset.canton.pruning.admin.v0.SetSchedule.Request) returns (com.digitalasset.canton.pruning.admin.v0.SetSchedule.Response); + rpc SetSchedule(com.digitalasset.canton.admin.pruning.v0.SetSchedule.Request) returns (com.digitalasset.canton.admin.pruning.v0.SetSchedule.Response); // Modify individual pruning schedule parameters. // - ``INVALID_ARGUMENT``: if the payload is malformed or no schedule is configured - rpc SetCron(com.digitalasset.canton.pruning.admin.v0.SetCron.Request) returns (com.digitalasset.canton.pruning.admin.v0.SetCron.Response); - rpc SetMaxDuration(com.digitalasset.canton.pruning.admin.v0.SetMaxDuration.Request) returns (com.digitalasset.canton.pruning.admin.v0.SetMaxDuration.Response); - rpc SetRetention(com.digitalasset.canton.pruning.admin.v0.SetRetention.Request) returns (com.digitalasset.canton.pruning.admin.v0.SetRetention.Response); + rpc SetCron(com.digitalasset.canton.admin.pruning.v0.SetCron.Request) returns (com.digitalasset.canton.admin.pruning.v0.SetCron.Response); + rpc SetMaxDuration(com.digitalasset.canton.admin.pruning.v0.SetMaxDuration.Request) returns (com.digitalasset.canton.admin.pruning.v0.SetMaxDuration.Response); + rpc SetRetention(com.digitalasset.canton.admin.pruning.v0.SetRetention.Request) returns (com.digitalasset.canton.admin.pruning.v0.SetRetention.Response); // Disable automatic pruning and remove the persisted schedule configuration. - rpc ClearSchedule(com.digitalasset.canton.pruning.admin.v0.ClearSchedule.Request) returns (com.digitalasset.canton.pruning.admin.v0.ClearSchedule.Response); + rpc ClearSchedule(com.digitalasset.canton.admin.pruning.v0.ClearSchedule.Request) returns (com.digitalasset.canton.admin.pruning.v0.ClearSchedule.Response); // Retrieve the automatic pruning configuration. - rpc GetSchedule(com.digitalasset.canton.pruning.admin.v0.GetSchedule.Request) returns (com.digitalasset.canton.pruning.admin.v0.GetSchedule.Response); + rpc GetSchedule(com.digitalasset.canton.admin.pruning.v0.GetSchedule.Request) returns (com.digitalasset.canton.admin.pruning.v0.GetSchedule.Response); // Retrieve pruning timestamp at or near the "beginning" of events. - rpc LocatePruningTimestamp(com.digitalasset.canton.pruning.admin.v0.LocatePruningTimestamp.Request) returns (com.digitalasset.canton.pruning.admin.v0.LocatePruningTimestamp.Response); + rpc LocatePruningTimestamp(com.digitalasset.canton.admin.pruning.v0.LocatePruningTimestamp.Request) returns (com.digitalasset.canton.admin.pruning.v0.LocatePruningTimestamp.Response); } message MediatorPruningRequest { diff --git a/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/enterprise_sequencer_administration_service.proto b/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/enterprise_sequencer_administration_service.proto index 23fa09330a..81e9874135 100644 --- a/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/enterprise_sequencer_administration_service.proto +++ b/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/enterprise_sequencer_administration_service.proto @@ -5,8 +5,8 @@ syntax = "proto3"; package com.digitalasset.canton.domain.admin.v0; +import "com/digitalasset/canton/admin/pruning/v0/pruning.proto"; import "com/digitalasset/canton/domain/admin/v1/sequencer_initialization_snapshot.proto"; -import "com/digitalasset/canton/pruning/admin/v0/pruning.proto"; import "google/protobuf/empty.proto"; import "google/protobuf/timestamp.proto"; @@ -29,22 +29,22 @@ service EnterpriseSequencerAdministrationService { // or duration. // - ``FAILED_PRECONDITION``: if automatic background pruning has not been enabled // or if invoked on a participant running the Community Edition. - rpc SetSchedule(com.digitalasset.canton.pruning.admin.v0.SetSchedule.Request) returns (com.digitalasset.canton.pruning.admin.v0.SetSchedule.Response); + rpc SetSchedule(com.digitalasset.canton.admin.pruning.v0.SetSchedule.Request) returns (com.digitalasset.canton.admin.pruning.v0.SetSchedule.Response); // Modify individual pruning schedule parameters. // - ``INVALID_ARGUMENT``: if the payload is malformed or no schedule is configured - rpc SetCron(com.digitalasset.canton.pruning.admin.v0.SetCron.Request) returns (com.digitalasset.canton.pruning.admin.v0.SetCron.Response); - rpc SetMaxDuration(com.digitalasset.canton.pruning.admin.v0.SetMaxDuration.Request) returns (com.digitalasset.canton.pruning.admin.v0.SetMaxDuration.Response); - rpc SetRetention(com.digitalasset.canton.pruning.admin.v0.SetRetention.Request) returns (com.digitalasset.canton.pruning.admin.v0.SetRetention.Response); + rpc SetCron(com.digitalasset.canton.admin.pruning.v0.SetCron.Request) returns (com.digitalasset.canton.admin.pruning.v0.SetCron.Response); + rpc SetMaxDuration(com.digitalasset.canton.admin.pruning.v0.SetMaxDuration.Request) returns (com.digitalasset.canton.admin.pruning.v0.SetMaxDuration.Response); + rpc SetRetention(com.digitalasset.canton.admin.pruning.v0.SetRetention.Request) returns (com.digitalasset.canton.admin.pruning.v0.SetRetention.Response); // Disable automatic pruning and remove the persisted schedule configuration. - rpc ClearSchedule(com.digitalasset.canton.pruning.admin.v0.ClearSchedule.Request) returns (com.digitalasset.canton.pruning.admin.v0.ClearSchedule.Response); + rpc ClearSchedule(com.digitalasset.canton.admin.pruning.v0.ClearSchedule.Request) returns (com.digitalasset.canton.admin.pruning.v0.ClearSchedule.Response); // Retrieve the automatic pruning configuration. - rpc GetSchedule(com.digitalasset.canton.pruning.admin.v0.GetSchedule.Request) returns (com.digitalasset.canton.pruning.admin.v0.GetSchedule.Response); + rpc GetSchedule(com.digitalasset.canton.admin.pruning.v0.GetSchedule.Request) returns (com.digitalasset.canton.admin.pruning.v0.GetSchedule.Response); // Retrieve pruning timestamp at or near the "beginning" of events. - rpc LocatePruningTimestamp(com.digitalasset.canton.pruning.admin.v0.LocatePruningTimestamp.Request) returns (com.digitalasset.canton.pruning.admin.v0.LocatePruningTimestamp.Response); + rpc LocatePruningTimestamp(com.digitalasset.canton.admin.pruning.v0.LocatePruningTimestamp.Request) returns (com.digitalasset.canton.admin.pruning.v0.LocatePruningTimestamp.Response); } message EthereumAccount { diff --git a/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/enterprise_sequencer_connection_service.proto b/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/enterprise_sequencer_connection_service.proto index a194d82aa8..9a7a9abc40 100644 --- a/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/enterprise_sequencer_connection_service.proto +++ b/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/enterprise_sequencer_connection_service.proto @@ -5,7 +5,7 @@ syntax = "proto3"; package com.digitalasset.canton.domain.admin.v0; -import "com/digitalasset/canton/domain/api/v0/sequencer_connection.proto"; +import "com/digitalasset/canton/admin/domain/v0/sequencer_connection.proto"; import "google/protobuf/empty.proto"; // service used by sequencer clients to manage connection to the sequencer @@ -18,7 +18,7 @@ service EnterpriseSequencerConnectionService { message GetConnectionRequest {} message GetConnectionResponse { - repeated com.digitalasset.canton.domain.api.v0.SequencerConnection sequencer_connections = 1; + repeated com.digitalasset.canton.admin.domain.v0.SequencerConnection sequencer_connections = 1; // This field determines the minimum level of agreement, or consensus, required among the sequencers before a message // is considered reliable and accepted by the system. // The value set here should not be zero. However, to maintain backward compatibility with older clients, a zero value @@ -26,7 +26,7 @@ message GetConnectionResponse { uint32 sequencerTrustThreshold = 2; } message SetConnectionRequest { - repeated com.digitalasset.canton.domain.api.v0.SequencerConnection sequencer_connections = 1; + repeated com.digitalasset.canton.admin.domain.v0.SequencerConnection sequencer_connections = 1; // This field determines the minimum level of agreement, or consensus, required among the sequencers before a message // is considered reliable and accepted by the system. // The value set here should not be zero. However, to maintain backward compatibility with older clients, a zero value diff --git a/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/mediator_initialization_service.proto b/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/mediator_initialization_service.proto index cc67c394f4..131db0650b 100644 --- a/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/mediator_initialization_service.proto +++ b/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/mediator_initialization_service.proto @@ -5,8 +5,8 @@ syntax = "proto3"; package com.digitalasset.canton.domain.admin.v0; +import "com/digitalasset/canton/admin/domain/v0/sequencer_connection.proto"; import "com/digitalasset/canton/crypto/v0/crypto.proto"; -import "com/digitalasset/canton/domain/api/v0/sequencer_connection.proto"; import "com/digitalasset/canton/protocol/v1/sequencing.proto"; import "com/digitalasset/canton/topology/admin/v0/topology_ext.proto"; import "google/protobuf/wrappers.proto"; @@ -30,7 +30,7 @@ message InitializeMediatorRequest { // manager is running) com.digitalasset.canton.protocol.v1.StaticDomainParameters domain_parameters = 4; // how should the member connect to the domain sequencer - com.digitalasset.canton.domain.api.v0.SequencerConnection sequencer_connection = 5; + com.digitalasset.canton.admin.domain.v0.SequencerConnection sequencer_connection = 5; // Optional fingerprint of the signing key the mediator should use. This key should already exist and have been // authorized during domain bootstrap google.protobuf.StringValue signing_key_fingerprint = 6; diff --git a/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/sequencer_administration_service.proto b/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/sequencer_administration_service.proto index 97bc2106b8..a3ddca476e 100644 --- a/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/sequencer_administration_service.proto +++ b/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v0/sequencer_administration_service.proto @@ -5,7 +5,7 @@ syntax = "proto3"; package com.digitalasset.canton.domain.admin.v0; -import "com/digitalasset/canton/traffic/v0/member_traffic_status.proto"; +import "com/digitalasset/canton/admin/traffic/v0/member_traffic_status.proto"; import "google/protobuf/empty.proto"; import "google/protobuf/timestamp.proto"; @@ -37,5 +37,5 @@ message TrafficControlStateRequest { } message TrafficControlStateResponse { - repeated com.digitalasset.canton.traffic.v0.MemberTrafficStatus traffic_states = 1; + repeated com.digitalasset.canton.admin.traffic.v0.MemberTrafficStatus traffic_states = 1; } diff --git a/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v2/mediator_initialization_service.proto b/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v2/mediator_initialization_service.proto index 14afbeadb8..186b42301b 100644 --- a/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v2/mediator_initialization_service.proto +++ b/canton-3x/community/domain/src/main/protobuf/com/digitalasset/canton/domain/admin/v2/mediator_initialization_service.proto @@ -5,7 +5,7 @@ syntax = "proto3"; package com.digitalasset.canton.domain.admin.v2; -import "com/digitalasset/canton/domain/api/v0/sequencer_connection.proto"; +import "com/digitalasset/canton/admin/domain/v0/sequencer_connection.proto"; import "com/digitalasset/canton/protocol/v1/sequencing.proto"; service MediatorInitializationService { @@ -22,7 +22,7 @@ message InitializeMediatorRequest { // parameters for the domain, must match the parameters used by all other domain entities com.digitalasset.canton.protocol.v1.StaticDomainParameters domain_parameters = 2; // how should the member connect to the domain sequencer - repeated com.digitalasset.canton.domain.api.v0.SequencerConnection sequencer_connections = 3; + repeated com.digitalasset.canton.admin.domain.v0.SequencerConnection sequencer_connections = 3; // This field determines the minimum level of agreement, or consensus, required among the sequencers before a message // is considered reliable and accepted by the system. uint32 sequencerTrustThreshold = 4; diff --git a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/initialization/DomainNodeSequencerClientFactory.scala b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/initialization/DomainNodeSequencerClientFactory.scala index 4b069b5ae1..7e71885ed5 100644 --- a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/initialization/DomainNodeSequencerClientFactory.scala +++ b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/initialization/DomainNodeSequencerClientFactory.scala @@ -22,7 +22,7 @@ import com.digitalasset.canton.sequencing.client.transports.{ } import com.digitalasset.canton.sequencing.client.{ RequestSigner, - SequencerClient, + RichSequencerClient, SequencerClientFactory, SequencerClientTransportFactory, } @@ -68,7 +68,7 @@ class DomainNodeSequencerClientFactory( materializer: Materializer, tracer: Tracer, traceContext: TraceContext, - ): EitherT[Future, String, SequencerClient] = + ): EitherT[Future, String, RichSequencerClient] = factory(member).create( member, sequencedEventStore, @@ -82,13 +82,14 @@ class DomainNodeSequencerClientFactory( connection: SequencerConnection, member: Member, requestSigner: RequestSigner, + allowReplay: Boolean = true, )(implicit executionContext: ExecutionContextExecutor, executionSequencerFactory: ExecutionSequencerFactory, materializer: Materializer, traceContext: TraceContext, ): EitherT[Future, String, SequencerClientTransport & SequencerClientTransportPekko] = - factory(member).makeTransport(connection, member, requestSigner) + factory(member).makeTransport(connection, member, requestSigner, allowReplay) private def factory(member: Member)(implicit executionContext: ExecutionContextExecutor diff --git a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/initialization/TopologyManagementInitialization.scala b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/initialization/TopologyManagementInitialization.scala index d5a739e268..ecff356079 100644 --- a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/initialization/TopologyManagementInitialization.scala +++ b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/initialization/TopologyManagementInitialization.scala @@ -27,6 +27,7 @@ import com.digitalasset.canton.protocol.messages.DomainTopologyTransactionMessag import com.digitalasset.canton.resource.Storage import com.digitalasset.canton.sequencing.client.{ RequestSigner, + RichSequencerClient, SendAsyncClientError, SendType, SequencerClient, @@ -63,7 +64,7 @@ import scala.concurrent.{ExecutionContext, ExecutionContextExecutor, Future} final case class TopologyManagementComponents( domainTopologyServiceHandler: DomainTopologyManagerEventHandler, client: DomainTopologyClientWithInit, - sequencerClient: SequencerClient, + sequencerClient: RichSequencerClient, processor: TopologyTransactionProcessor, dispatcher: DomainTopologyDispatcher, timeouts: ProcessingTimeout, diff --git a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/mediator/ConfirmationResponseProcessor.scala b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/mediator/ConfirmationResponseProcessor.scala index cbb293341b..168acbbb8b 100644 --- a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/mediator/ConfirmationResponseProcessor.scala +++ b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/mediator/ConfirmationResponseProcessor.scala @@ -189,7 +189,7 @@ private[mediator] class ConfirmationResponseProcessor( logger .info( - s"Phase 6: Request ${requestId}: Timeout in state ${responseAggregation.state} at $timestamp" + s"Phase 6: Request ${requestId.unwrap}: Timeout in state ${responseAggregation.state} at $timestamp" ) val timeout = responseAggregation.timeout(version = timestamp) diff --git a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/mediator/Mediator.scala b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/mediator/Mediator.scala index 87b00fd8be..fb8ca49c36 100644 --- a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/mediator/Mediator.scala +++ b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/mediator/Mediator.scala @@ -28,7 +28,7 @@ import com.digitalasset.canton.protocol.messages.{ } import com.digitalasset.canton.protocol.{DynamicDomainParametersWithValidity, RequestId} import com.digitalasset.canton.sequencing.* -import com.digitalasset.canton.sequencing.client.SequencerClient +import com.digitalasset.canton.sequencing.client.RichSequencerClient import com.digitalasset.canton.sequencing.handlers.DiscardIgnoredEvents import com.digitalasset.canton.sequencing.protocol.{ ClosedEnvelope, @@ -64,7 +64,7 @@ private[mediator] class Mediator( val domain: DomainId, val mediatorId: MediatorId, @VisibleForTesting - val sequencerClient: SequencerClient, + val sequencerClient: RichSequencerClient, val topologyClient: DomainTopologyClientWithInit, private[canton] val syncCrypto: DomainSyncCryptoClient, topologyTransactionProcessor: TopologyTransactionProcessorCommon, diff --git a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/mediator/MediatorNodeCommon.scala b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/mediator/MediatorNodeCommon.scala index 19a901502d..57ac47542a 100644 --- a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/mediator/MediatorNodeCommon.scala +++ b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/mediator/MediatorNodeCommon.scala @@ -253,6 +253,7 @@ trait MediatorNodeBootstrapCommon[ info.sequencerConnections.default, mediatorId, requestSigner, + allowReplay = false, ) .flatMap( ResourceUtil.withResourceEitherT(_)( diff --git a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/mediator/MediatorReplicaManager.scala b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/mediator/MediatorReplicaManager.scala index 08dbe1b54f..c459fae6e7 100644 --- a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/mediator/MediatorReplicaManager.scala +++ b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/mediator/MediatorReplicaManager.scala @@ -11,7 +11,7 @@ import com.digitalasset.canton.lifecycle.{AsyncOrSyncCloseable, FlagCloseableAsy import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.networking.grpc.{CantonMutableHandlerRegistry, GrpcDynamicService} import com.digitalasset.canton.tracing.TraceContext -import com.digitalasset.canton.util.{EitherTUtil, ErrorUtil, SingleUseCell} +import com.digitalasset.canton.util.{EitherTUtil, SingleUseCell} import java.util.concurrent.atomic.AtomicReference import scala.concurrent.{ExecutionContext, Future} @@ -22,16 +22,9 @@ trait MediatorReplicaManager extends NamedLogging with FlagCloseableAsync { : SingleUseCell[() => EitherT[Future, String, MediatorRuntime]] = new SingleUseCell - protected def getMediatorRuntimeFactory()(implicit - traceContext: TraceContext - ): () => EitherT[Future, String, MediatorRuntime] = - mediatorRuntimeFactoryRef.getOrElse { - ErrorUtil.internalError( - new IllegalStateException( - "Set active called before mediator runtime factory was initialized" - ) - ) - } + protected def getMediatorRuntimeFactory() + : Option[() => EitherT[Future, String, MediatorRuntime]] = + mediatorRuntimeFactoryRef.get protected val mediatorRuntimeRef = new AtomicReference[Option[MediatorRuntime]](None) @@ -92,14 +85,14 @@ class CommunityMediatorReplicaManager( adminServiceRegistry.addServiceU(domainTimeService.serviceDescriptor) - for { - mediatorRuntime <- EitherTUtil.toFuture( - getMediatorRuntimeFactory().apply().leftMap(new MediatorReplicaManagerException(_)) - ) + val result = for { + mediatorRuntime <- factory() } yield { mediatorRuntimeRef.set(Some(mediatorRuntime)) domainTimeService.setInstance(mediatorRuntime.timeService) } + + EitherTUtil.toFuture(result.leftMap(new MediatorReplicaManagerException(_))) } override def isActive: Boolean = true diff --git a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/mediator/MediatorRuntimeFactory.scala b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/mediator/MediatorRuntimeFactory.scala index dd41aa3886..695a7901cb 100644 --- a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/mediator/MediatorRuntimeFactory.scala +++ b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/mediator/MediatorRuntimeFactory.scala @@ -20,7 +20,7 @@ import com.digitalasset.canton.lifecycle.FlagCloseable import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.networking.grpc.StaticGrpcServices import com.digitalasset.canton.resource.Storage -import com.digitalasset.canton.sequencing.client.SequencerClient +import com.digitalasset.canton.sequencing.client.RichSequencerClient import com.digitalasset.canton.store.{SequencedEventStore, SequencerCounterTrackerStore} import com.digitalasset.canton.time.{Clock, GrpcDomainTimeService} import com.digitalasset.canton.topology.* @@ -91,7 +91,7 @@ trait MediatorRuntimeFactory { storage: Storage, sequencerCounterTrackerStore: SequencerCounterTrackerStore, sequencedEventStore: SequencedEventStore, - sequencerClient: SequencerClient, + sequencerClient: RichSequencerClient, syncCrypto: DomainSyncCryptoClient, topologyClient: DomainTopologyClientWithInit, topologyTransactionProcessor: TopologyTransactionProcessorCommon, @@ -118,7 +118,7 @@ object CommunityMediatorRuntimeFactory extends MediatorRuntimeFactory { storage: Storage, sequencerCounterTrackerStore: SequencerCounterTrackerStore, sequencedEventStore: SequencedEventStore, - sequencerClient: SequencerClient, + sequencerClient: RichSequencerClient, syncCrypto: DomainSyncCryptoClient, topologyClient: DomainTopologyClientWithInit, topologyTransactionProcessor: TopologyTransactionProcessorCommon, diff --git a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/SequencerRuntimeForSeparateNode.scala b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/SequencerRuntimeForSeparateNode.scala index dd82efa2b6..dbad86b31c 100644 --- a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/SequencerRuntimeForSeparateNode.scala +++ b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/SequencerRuntimeForSeparateNode.scala @@ -116,43 +116,44 @@ class SequencerRuntimeForSeparateNode( loggerFactory, ) - private val client: SequencerClient = new SequencerClientImpl( - domainId, - sequencerId, - SequencerTransports.default( + private val client: SequencerClient = + new SequencerClientImplPekko[DirectSequencerClientTransport.SubscriptionError]( + domainId, sequencerId, - new DirectSequencerClientTransport( - sequencer, - localNodeParameters.processingTimeouts, - loggerFactory, + SequencerTransports.default( + sequencerId, + new DirectSequencerClientTransport( + sequencer, + localNodeParameters.processingTimeouts, + loggerFactory, + ), + ), + localNodeParameters.sequencerClient, + testingConfig, + staticDomainParameters.protocolVersion, + sequencerDomainParamsLookup, + localNodeParameters.processingTimeouts, + // Since the sequencer runtime trusts itself, there is no point in validating the events. + SequencedEventValidatorFactory.noValidation(domainId, warn = false), + clock, + RequestSigner(syncCrypto, staticDomainParameters.protocolVersion), + sequencedEventStore, + new SendTracker( + Map(), + SendTrackerStore(storage), + metrics.sequencerClient, + loggerFactory, + timeouts, ), - ), - localNodeParameters.sequencerClient, - testingConfig, - staticDomainParameters.protocolVersion, - sequencerDomainParamsLookup, - localNodeParameters.processingTimeouts, - // Since the sequencer runtime trusts itself, there is no point in validating the events. - SequencedEventValidatorFactory.noValidation(domainId, warn = false), - clock, - RequestSigner(syncCrypto, staticDomainParameters.protocolVersion), - sequencedEventStore, - new SendTracker( - Map(), - SendTrackerStore(storage), metrics.sequencerClient, + None, + replayEnabled = false, + syncCrypto.pureCrypto, + localNodeParameters.loggingConfig, loggerFactory, - timeouts, - ), - metrics.sequencerClient, - None, - replayEnabled = false, - syncCrypto.pureCrypto, - localNodeParameters.loggingConfig, - loggerFactory, - futureSupervisor, - sequencer.firstSequencerCounterServeableForSequencer, - ) + futureSupervisor, + sequencer.firstSequencerCounterServeableForSequencer, + ) private val timeTracker = DomainTimeTracker( timeTrackerConfig, clock, diff --git a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/config/CommunitySequencerNodeXConfig.scala b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/config/CommunitySequencerNodeXConfig.scala index 625a5a741f..66a8a3251d 100644 --- a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/config/CommunitySequencerNodeXConfig.scala +++ b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/config/CommunitySequencerNodeXConfig.scala @@ -28,7 +28,7 @@ final case class CommunitySequencerNodeXConfig( override val timeTracker: DomainTimeTrackerConfig = DomainTimeTrackerConfig(), override val sequencerClient: SequencerClientConfig = SequencerClientConfig(), override val caching: CachingConfigs = CachingConfigs(), - parameters: SequencerNodeParameterConfig = SequencerNodeParameterConfig(), + override val parameters: SequencerNodeParameterConfig = SequencerNodeParameterConfig(), override val health: SequencerHealthConfig = SequencerHealthConfig(), override val monitoring: NodeMonitoringConfig = NodeMonitoringConfig(), override val topologyX: TopologyXConfig = TopologyXConfig(), diff --git a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/config/SequencerNodeConfigCommon.scala b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/config/SequencerNodeConfigCommon.scala index 14891c8225..bf000d0638 100644 --- a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/config/SequencerNodeConfigCommon.scala +++ b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/config/SequencerNodeConfigCommon.scala @@ -11,20 +11,20 @@ import com.digitalasset.canton.sequencing.client.SequencerClientConfig import java.io.File abstract class SequencerNodeConfigCommon( - val init: SequencerNodeInitConfigCommon, + override val init: SequencerNodeInitConfigCommon, val publicApi: PublicServerConfig, - val adminApi: AdminServerConfig, - val storage: StorageConfig, - val crypto: CryptoConfig, + override val adminApi: AdminServerConfig, + override val storage: StorageConfig, + override val crypto: CryptoConfig, val sequencer: SequencerConfig, val auditLogging: Boolean, val serviceAgreement: Option[File], val timeTracker: DomainTimeTrackerConfig, - val sequencerClient: SequencerClientConfig, - val caching: CachingConfigs, - parameters: SequencerNodeParameterConfig, + override val sequencerClient: SequencerClientConfig, + override val caching: CachingConfigs, + override val parameters: SequencerNodeParameterConfig, val health: SequencerHealthConfig, - val monitoring: NodeMonitoringConfig, + override val monitoring: NodeMonitoringConfig, ) extends LocalNodeConfig { override def clientAdminApi: ClientConfig = adminApi.clientConfig diff --git a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/sequencer/DirectSequencerClientTransport.scala b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/sequencer/DirectSequencerClientTransport.scala index c205f316ef..4a0e9c5388 100644 --- a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/sequencer/DirectSequencerClientTransport.scala +++ b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/sequencer/DirectSequencerClientTransport.scala @@ -4,11 +4,17 @@ package com.digitalasset.canton.domain.sequencing.sequencer import cats.data.EitherT +import cats.syntax.either.* import com.daml.nameof.NameOf.functionFullName +import com.digitalasset.canton.DiscardOps +import com.digitalasset.canton.concurrent.DirectExecutionContext import com.digitalasset.canton.config.ProcessingTimeout +import com.digitalasset.canton.domain.sequencing.sequencer.errors.CreateSubscriptionError import com.digitalasset.canton.domain.sequencing.service.DirectSequencerSubscriptionFactory -import com.digitalasset.canton.lifecycle.SyncCloseable -import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} +import com.digitalasset.canton.health.{AtomicHealthComponent, ComponentHealthState} +import com.digitalasset.canton.lifecycle.{OnShutdownRunner, SyncCloseable} +import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} +import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging, TracedLogger} import com.digitalasset.canton.sequencing.SerializedEventHandler import com.digitalasset.canton.sequencing.client.* import com.digitalasset.canton.sequencing.client.transports.{ @@ -27,10 +33,13 @@ import com.digitalasset.canton.sequencing.protocol.{ TopologyStateForInitResponse, } import com.digitalasset.canton.tracing.TraceContext +import com.digitalasset.canton.util.PekkoUtil.DelayedKillSwitch +import com.digitalasset.canton.util.PekkoUtil.syntax.* import com.digitalasset.canton.util.Thereafter.syntax.* -import com.digitalasset.canton.util.{ErrorUtil, FutureUtil} -import com.digitalasset.canton.{DiscardOps, Uninhabited} +import com.digitalasset.canton.util.{ErrorUtil, FutureUtil, PekkoUtil} import org.apache.pekko.stream.Materializer +import org.apache.pekko.stream.scaladsl.Source +import org.apache.pekko.{Done, NotUsed} import java.util.concurrent.atomic.AtomicReference import scala.concurrent.duration.Duration @@ -48,6 +57,7 @@ class DirectSequencerClientTransport( extends SequencerClientTransport with SequencerClientTransportPekko with NamedLogging { + import DirectSequencerClientTransport.* private val subscriptionFactory = new DirectSequencerSubscriptionFactory(sequencer, timeouts, loggerFactory) @@ -158,12 +168,43 @@ class DirectSequencerClientTransport( // unlikely there will be any errors with this direct transport implementation SubscriptionErrorRetryPolicy.never - override type SubscriptionError = Uninhabited + override type SubscriptionError = DirectSequencerClientTransport.SubscriptionError override def subscribe(request: SubscriptionRequest)(implicit traceContext: TraceContext - ): SequencerSubscriptionPekko[SubscriptionError] = - ??? // TODO(#13789) implement this + ): SequencerSubscriptionPekko[SubscriptionError] = { + val sourceF = sequencer + .read(request.member, request.counter) + .value + .map { + case Left(creationError) => + Source + .single(Left(SubscriptionCreationError(creationError))) + .mapMaterializedValue((_: NotUsed) => + (PekkoUtil.noOpKillSwitch, Future.successful(Done)) + ) + case Right(source) => source.map(_.leftMap(SequencedEventError)) + } + val health = new DirectSequencerClientTransportHealth(logger) + val source = Source + .futureSource(sourceF) + .watchTermination() { (matF, terminationF) => + val directExecutionContext = DirectExecutionContext(noTracingLogger) + val killSwitchF = matF.map { case (killSwitch, _) => killSwitch }(directExecutionContext) + val killSwitch = new DelayedKillSwitch(killSwitchF, noTracingLogger) + val doneF = matF + .flatMap { case (_, doneF) => doneF }(directExecutionContext) + .flatMap(_ => terminationF)(directExecutionContext) + .thereafter { _ => + logger.debug("Closing direct sequencer subscription transport") + health.associatedOnShutdownRunner.close() + } + (killSwitch, doneF) + } + .injectKillSwitch { case (killSwitch, _) => killSwitch } + + SequencerSubscriptionPekko(source, health) + } override def subscribeUnauthenticated(request: SubscriptionRequest)(implicit traceContext: TraceContext @@ -189,3 +230,24 @@ class DirectSequencerClientTransport( "downloadTopologyStateForInit is not implemented for DirectSequencerClientTransport" ) } + +object DirectSequencerClientTransport { + sealed trait SubscriptionError extends Product with Serializable with PrettyPrinting { + override def pretty: Pretty[SubscriptionError.this.type] = adHocPrettyInstance + } + final case class SubscriptionCreationError(error: CreateSubscriptionError) + extends SubscriptionError + final case class SequencedEventError(error: SequencerSubscriptionError.SequencedEventError) + extends SubscriptionError + + private class DirectSequencerClientTransportHealth(override protected val logger: TracedLogger) + extends AtomicHealthComponent { + override def name: String = "direct-sequencer-client-transport" + + override protected def initialHealthState: ComponentHealthState = + ComponentHealthState.Ok() + + override lazy val associatedOnShutdownRunner: AutoCloseable & OnShutdownRunner = + new OnShutdownRunner.PureOnShutdownRunner(logger) + } +} diff --git a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/sequencer/SequencerFactory.scala b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/sequencer/SequencerFactory.scala index 4d7b9eac6d..f214c60ae1 100644 --- a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/sequencer/SequencerFactory.scala +++ b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/sequencer/SequencerFactory.scala @@ -121,3 +121,33 @@ trait MkSequencerFactory { )(implicit executionContext: ExecutionContext): SequencerFactory } + +object CommunitySequencerFactory extends MkSequencerFactory { + override def apply( + protocolVersion: ProtocolVersion, + health: Option[SequencerHealthConfig], + clock: Clock, + scheduler: ScheduledExecutorService, + metrics: SequencerMetrics, + storage: Storage, + topologyClientMember: Member, + nodeParameters: CantonNodeParameters, + loggerFactory: NamedLoggerFactory, + )(sequencerConfig: SequencerConfig)(implicit + executionContext: ExecutionContext + ): SequencerFactory = sequencerConfig match { + case communityConfig: CommunitySequencerConfig.Database => + new CommunityDatabaseSequencerFactory( + communityConfig, + metrics, + storage, + protocolVersion, + topologyClientMember, + nodeParameters, + loggerFactory, + ) + + case config: SequencerConfig => + throw new UnsupportedOperationException(s"Invalid config type ${config.getClass}") + } +} diff --git a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/service/DirectSequencerSubscriptionFactory.scala b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/service/DirectSequencerSubscriptionFactory.scala index e92a073791..f3a8c01f00 100644 --- a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/service/DirectSequencerSubscriptionFactory.scala +++ b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/service/DirectSequencerSubscriptionFactory.scala @@ -56,5 +56,4 @@ class DirectSequencerSubscriptionFactory( subscription } } - } diff --git a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/service/GrpcEnterpriseSequencerAdministrationService.scala b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/service/GrpcEnterpriseSequencerAdministrationService.scala index e2255b54a7..51560a1ac7 100644 --- a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/service/GrpcEnterpriseSequencerAdministrationService.scala +++ b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/sequencing/service/GrpcEnterpriseSequencerAdministrationService.scala @@ -6,12 +6,12 @@ package com.digitalasset.canton.domain.sequencing.service import cats.data.EitherT import cats.syntax.either.* import com.digitalasset.canton.admin.grpc.{GrpcPruningScheduler, HasPruningScheduler} +import com.digitalasset.canton.admin.pruning.v0.LocatePruningTimestamp import com.digitalasset.canton.config.RequireTypes.PositiveInt import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.domain.admin.v0 import com.digitalasset.canton.domain.sequencing.sequencer.{LedgerIdentity, PruningError, Sequencer} import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} -import com.digitalasset.canton.pruning.admin.v0.LocatePruningTimestamp import com.digitalasset.canton.scheduler.PruningScheduler import com.digitalasset.canton.serialization.ProtoConverter import com.digitalasset.canton.topology.Member diff --git a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/service/GrpcSequencerConnectionService.scala b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/service/GrpcSequencerConnectionService.scala index 77015e1d2e..b24e258413 100644 --- a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/service/GrpcSequencerConnectionService.scala +++ b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/service/GrpcSequencerConnectionService.scala @@ -22,7 +22,7 @@ import com.digitalasset.canton.networking.grpc.CantonMutableHandlerRegistry import com.digitalasset.canton.sequencing.client.SequencerClient.SequencerTransports import com.digitalasset.canton.sequencing.client.{ RequestSigner, - SequencerClient, + RichSequencerClient, SequencerClientTransportFactory, } import com.digitalasset.canton.sequencing.{ @@ -123,7 +123,7 @@ class GrpcSequencerConnectionService( object GrpcSequencerConnectionService { trait UpdateSequencerClient { - def set(client: SequencerClient): Unit + def set(client: RichSequencerClient): Unit } def setup[C](member: Member)( @@ -142,8 +142,8 @@ object GrpcSequencerConnectionService { traceContext: TraceContext, errorLoggingContext: ErrorLoggingContext, closeContext: CloseContext, - ) = { - val clientO = new AtomicReference[Option[SequencerClient]](None) + ): UpdateSequencerClient = { + val clientO = new AtomicReference[Option[RichSequencerClient]](None) registry.addServiceU( EnterpriseSequencerConnectionService.bindService( new GrpcSequencerConnectionService( @@ -203,7 +203,7 @@ object GrpcSequencerConnectionService { ) ) new UpdateSequencerClient { - override def set(client: SequencerClient): Unit = clientO.set(Some(client)) + override def set(client: RichSequencerClient): Unit = clientO.set(Some(client)) } } diff --git a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/topology/DomainTopologyDispatcher.scala b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/topology/DomainTopologyDispatcher.scala index 3566a5c053..68af95d9d7 100644 --- a/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/topology/DomainTopologyDispatcher.scala +++ b/canton-3x/community/domain/src/main/scala/com/digitalasset/canton/domain/topology/DomainTopologyDispatcher.scala @@ -953,7 +953,7 @@ object DomainTopologySender extends TopologyDispatchingErrorGroup { logger.debug(s"Attempting to dispatch ${message}") FutureUtil.doNotAwait( send(batch, callback).thereafter { - case x @ Success(UnlessShutdown.Outcome(Right(_))) => + case Success(UnlessShutdown.Outcome(Right(_))) => // nice, the sequencer seems to be accepting our request case Success(UnlessShutdown.Outcome(Left(RequestRefused(error)))) @@ -966,9 +966,11 @@ object DomainTopologySender extends TopologyDispatchingErrorGroup { clock .scheduleAfter(_ => dispatch(), java.time.Duration.ofMillis(retryInterval.toMillis)) .discard + case Success(UnlessShutdown.Outcome(Left(error))) => TopologyDispatchingInternalError.AsyncResultError(error).discard stopDispatching("Stopping due to an unexpected async result error") + case Success(UnlessShutdown.AbortedDueToShutdown) => abortDueToShutdown() case Failure(ex) => diff --git a/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/error/SequencerBaseErrorTest.scala b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/error/SequencerBaseErrorTest.scala new file mode 100644 index 0000000000..55b2fecf45 --- /dev/null +++ b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/error/SequencerBaseErrorTest.scala @@ -0,0 +1,20 @@ +// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.domain.sequencing.error + +import com.digitalasset.canton.BaseTest +import com.digitalasset.canton.error.{FabricErrors, SequencerBaseError} +import org.scalatest.wordspec.AnyWordSpec + +class SequencerBaseErrorTest extends AnyWordSpec with BaseTest { + + "stringFromContext" should { + "produce a string" in { + SequencerBaseError.stringFromContext( + FabricErrors.TransactionErrors.InvalidTransaction.Warn("fcn", "msg", 1L) + ) shouldBe "FABRIC_TRANSACTION_INVALID(5,0): At block 1 found invalid fcn transaction. That indicates malicious " + + "or faulty behavior, so skipping it. Error: msg; blockHeight=1, fcn=fcn, test=SequencerBaseErrorTest" + } + } +} diff --git a/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/integrations/state/EphemeralStateTest.scala b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/integrations/state/EphemeralStateTest.scala new file mode 100644 index 0000000000..3c9e409812 --- /dev/null +++ b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/integrations/state/EphemeralStateTest.scala @@ -0,0 +1,51 @@ +// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.domain.sequencing.integrations.state + +import com.digitalasset.canton.data.CantonTimestamp +import com.digitalasset.canton.domain.sequencing.sequencer.{ + InternalSequencerPruningStatus, + SequencerMemberStatus, +} +import com.digitalasset.canton.topology.ParticipantId +import com.digitalasset.canton.{BaseTest, SequencerCounter} +import org.scalatest.wordspec.AnyWordSpec + +class EphemeralStateTest extends AnyWordSpec with BaseTest { + private val t1 = CantonTimestamp.Epoch.plusSeconds(1) + private val alice = ParticipantId("alice") + private val bob = ParticipantId("bob") + private val carlos = ParticipantId("carlos") + + "nextCounters" should { + "throw error if member is not registered" in { + val state = EphemeralState( + Map.empty, + Map.empty, + InternalSequencerPruningStatus( + t1, + Seq(SequencerMemberStatus(alice, t1, None), SequencerMemberStatus(bob, t1, None)), + ), + ) + an[IllegalArgumentException] should be thrownBy state.tryNextCounters(Set(carlos)) + } + + "increment existing counters and otherwise use genesis for members without an existing counter" in { + val counters = EphemeralState( + Map(alice -> SequencerCounter(2)), + Map.empty, + InternalSequencerPruningStatus( + t1, + Seq(SequencerMemberStatus(alice, t1, None), SequencerMemberStatus(bob, t1, None)), + ), + ) + .tryNextCounters(Set(alice, bob)) + + counters should contain.only( + alice -> SequencerCounter(3), + bob -> SequencerCounter.Genesis, + ) + } + } +} diff --git a/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/integrations/state/SequencerStateManagerStoreTest.scala b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/integrations/state/SequencerStateManagerStoreTest.scala new file mode 100644 index 0000000000..e49cc8bc86 --- /dev/null +++ b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/integrations/state/SequencerStateManagerStoreTest.scala @@ -0,0 +1,803 @@ +// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.domain.sequencing.integrations.state + +import cats.syntax.either.* +import cats.syntax.option.* +import com.daml.nonempty.NonEmpty +import com.digitalasset.canton.config.CantonRequireTypes.String73 +import com.digitalasset.canton.config.RequireTypes.{NonNegativeLong, PositiveInt} +import com.digitalasset.canton.crypto.provider.symbolic.SymbolicCrypto +import com.digitalasset.canton.crypto.{HashPurpose, TestHash} +import com.digitalasset.canton.data.CantonTimestamp +import com.digitalasset.canton.domain.sequencing.sequencer.InFlightAggregation.AggregationBySender +import com.digitalasset.canton.domain.sequencing.sequencer.store.SaveLowerBoundError.BoundLowerThanExisting +import com.digitalasset.canton.domain.sequencing.sequencer.{ + InFlightAggregation, + InternalSequencerPruningStatus, + SequencerMemberStatus, +} +import com.digitalasset.canton.sequencing.protocol.{SequencerErrors, *} +import com.digitalasset.canton.sequencing.{OrdinarySerializedEvent, SequencerTestUtils} +import com.digitalasset.canton.store.SequencedEventStore.OrdinarySequencedEvent +import com.digitalasset.canton.topology.{ + DefaultTestIdentities, + Member, + ParticipantId, + TestingTopology, + UniqueIdentifier, +} +import com.digitalasset.canton.tracing.TraceContext +import com.digitalasset.canton.tracing.TraceContext.withNewTraceContext +import com.digitalasset.canton.{BaseTest, ProtocolVersionChecksAsyncWordSpec, SequencerCounter} +import com.google.protobuf.ByteString +import monocle.macros.syntax.lens.* +import org.apache.pekko.NotUsed +import org.apache.pekko.actor.ActorSystem +import org.apache.pekko.stream.Materializer +import org.apache.pekko.stream.scaladsl.{Sink, Source} +import org.scalatest.BeforeAndAfterAll +import org.scalatest.wordspec.AsyncWordSpec + +import scala.concurrent.duration.* +import scala.concurrent.{Await, Future} + +trait SequencerStateManagerStoreTest + extends AsyncWordSpec + with BaseTest + with BeforeAndAfterAll + with ProtocolVersionChecksAsyncWordSpec { + + @SuppressWarnings(Array("org.wartremover.warts.Var", "org.wartremover.warts.Null")) + private var actorSystem: ActorSystem = _ + @SuppressWarnings(Array("org.wartremover.warts.Var", "org.wartremover.warts.Null")) + private var materializer: Materializer = _ + private lazy val domainId = DefaultTestIdentities.domainId + private lazy val syncCryptoApi = + TestingTopology(domains = Set(domainId)) + .build() + .forOwnerAndDomain(DefaultTestIdentities.sequencerId, domainId) + .currentSnapshotApproximation + + // we don't do any signature verification in these tests so any signature that will deserialize with the testing crypto api is fine + private lazy val signature = { + val hash = + syncCryptoApi.pureCrypto.digest( + HashPurpose.SequencedEventSignature, + ByteString.copyFromUtf8("signature"), + ) + Await.result(syncCryptoApi.sign(hash).value, 10.seconds).valueOr(err => fail(err.toString)) + } + + override def beforeAll(): Unit = { + actorSystem = ActorSystem("sequencer-store-test") + materializer = Materializer(actorSystem) + super.beforeAll() + } + + override def afterAll(): Unit = { + try super.afterAll() + finally { + materializer.shutdown() + Await.result(actorSystem.terminate(), 10.seconds) + } + } + + def sequencerStateManagerStore(mk: () => SequencerStateManagerStore): Unit = { + val alice = ParticipantId(UniqueIdentifier.tryCreate("participant", "alice")) + val bob = ParticipantId(UniqueIdentifier.tryCreate("participant", "bob")) + val carlos = ParticipantId(UniqueIdentifier.tryCreate("participant", "carlos")) + val message = ByteString.copyFromUtf8("test-message") + + def ts(epochSeconds: Int): CantonTimestamp = + CantonTimestamp.Epoch.plusSeconds(epochSeconds.toLong) + + val t1 = ts(1) + val t2 = ts(2) + val t3 = ts(3) + val t4 = ts(4) + + "read at timestamp" should { + "hydrate a correct empty state when there have been no updates" in { + val store = mk() + for { + head <- store.readAtBlockTimestamp(CantonTimestamp.Epoch) + } yield { + head.registeredMembers shouldBe empty + head.heads shouldBe empty + } + } + + "hydrate correct state from previous updates" in + withNewTraceContext { implicit traceContext => + val store = mk() + + for { + _ <- store.addMember(alice, t1) + _ <- store.addMember(bob, t1) + _ <- store.addEvents( + Map(alice -> send(alice, SequencerCounter(0), t1, message)), + Map.empty, + ) + _ <- store.addEvents( + Map( + alice -> mockDeliver(1, t2), + bob -> mockDeliver(0, t2), + ), + Map.empty, + ) + _ <- store.addMember(carlos, t2) + stateAtT1 <- store.readAtBlockTimestamp(t1) + head <- store.readAtBlockTimestamp(t2) + } yield { + stateAtT1.registeredMembers should contain.only(alice, bob) + stateAtT1.heads(alice) shouldBe SequencerCounter(0) + stateAtT1.heads.keys should contain only alice + + head.registeredMembers should contain.only(alice, bob, carlos) + head.heads(alice) shouldBe SequencerCounter(1) + head.heads(bob) shouldBe SequencerCounter(0) + head.heads.keys should not contain carlos + } + } + + "hydrate traffic state from previous updates" in + withNewTraceContext { implicit traceContext => + val store = mk() + val trafficStateAlice = TrafficState( + NonNegativeLong.tryCreate(5L), + NonNegativeLong.tryCreate(6L), + NonNegativeLong.tryCreate(7L), + t1, + ) + val trafficStateAlice2 = + trafficStateAlice.copy( + extraTrafficRemainder = NonNegativeLong.tryCreate(64L), + timestamp = t2, + ) + val trafficStateBob = + trafficStateAlice.copy( + extraTrafficRemainder = NonNegativeLong.tryCreate(54L), + timestamp = t2, + ) + for { + _ <- store.addMember(alice, t1) + _ <- store.addMember(bob, t1) + _ <- store.addEvents( + Map(alice -> send(alice, SequencerCounter(0), t1, message)), + Map(alice -> trafficStateAlice), + ) + _ <- store.addEvents( + Map( + alice -> mockDeliver(1, t2), + bob -> mockDeliver(0, t2), + ), + Map(alice -> trafficStateAlice2, bob -> trafficStateBob), + ) + _ <- store.addMember(carlos, t2) + stateAtT1 <- store.readAtBlockTimestamp(t1) + head <- store.readAtBlockTimestamp(t2) + } yield { + stateAtT1.registeredMembers should contain.only(alice, bob) + stateAtT1.heads(alice) shouldBe SequencerCounter(0) + stateAtT1.heads.keys should contain only alice + stateAtT1.trafficState(alice) shouldBe trafficStateAlice + stateAtT1.trafficState.keys should contain only alice + + head.registeredMembers should contain.only(alice, bob, carlos) + head.heads(alice) shouldBe SequencerCounter(1) + head.heads(bob) shouldBe SequencerCounter(0) + head.heads.keys should not contain carlos + head.trafficState(alice) shouldBe trafficStateAlice2 + head.trafficState(bob) shouldBe trafficStateBob + head.trafficState.keys should not contain carlos + } + } + + "take into consideration timestamps of adding members" in withNewTraceContext { + implicit traceContext => + val store = mk() + + for { + _ <- store.addMember(alice, t1) + _ <- store.addEvents( + Map(alice -> send(alice, SequencerCounter(0), t1, message)), + Map.empty, + ) + _ <- store.addMember(bob, t2) + head <- store.readAtBlockTimestamp(t2) + } yield { + head.registeredMembers should contain.only(alice, bob) + head.heads(alice) shouldBe SequencerCounter(0) + } + } + + "reconstruct the aggregation state" in withNewTraceContext { implicit traceContext => + val store = mk() + val aggregationId1 = AggregationId(TestHash.digest(1)) + val aggregationId2 = AggregationId(TestHash.digest(2)) + val aggregationId3 = AggregationId(TestHash.digest(3)) + val rule = AggregationRule( + NonEmpty(Seq, alice, bob), + threshold = PositiveInt.tryCreate(2), + testedProtocolVersion, + ) + val signatureAlice1 = SymbolicCrypto.signature( + ByteString.copyFromUtf8("signatureAlice1"), + alice.uid.namespace.fingerprint, + ) + val signatureAlice2 = SymbolicCrypto.signature( + ByteString.copyFromUtf8("signatureAlice2"), + alice.uid.namespace.fingerprint, + ) + val signatureAlice3 = SymbolicCrypto.signature( + ByteString.copyFromUtf8("signatureAlice3"), + alice.uid.namespace.fingerprint, + ) + val signatureBob = SymbolicCrypto.signature( + ByteString.copyFromUtf8("signatureBob"), + bob.uid.namespace.fingerprint, + ) + + val inFlightAggregation1 = InFlightAggregation( + rule, + t4, + alice -> AggregationBySender( + t2, + Seq(Seq(signatureAlice1), Seq(signatureAlice2, signatureAlice3)), + ), + bob -> AggregationBySender(t3, Seq(Seq(signatureBob), Seq.empty)), + ) + val inFlightAggregation2 = InFlightAggregation(rule, t3) + val inFlightAggregation3 = InFlightAggregation( + rule, + t4, + alice -> AggregationBySender( + t4.immediatePredecessor, + Seq(Seq(signatureAlice1), Seq.empty, Seq(signatureAlice2)), + ), + ) + + for { + _ <- store.addMember(alice, t1) + _ <- store.addMember(bob, t1) + _ <- store.addInFlightAggregationUpdates( + Map( + aggregationId1 -> inFlightAggregation1.asUpdate, + aggregationId2 -> inFlightAggregation2.asUpdate, + aggregationId3 -> inFlightAggregation3.asUpdate, + ) + ) + head2pred <- store.readAtBlockTimestamp(t2.immediatePredecessor) + head2 <- store.readAtBlockTimestamp(t2) + head3 <- store.readAtBlockTimestamp(t3) + head4pred <- store.readAtBlockTimestamp(t4.immediatePredecessor) + head4 <- store.readAtBlockTimestamp(t4) + } yield { + // All aggregations by sender have later timestamps and the in-flight aggregations are therefore considered inexistent + head2pred.inFlightAggregations shouldBe Map.empty + head2.inFlightAggregations shouldBe Map( + aggregationId1 -> inFlightAggregation1 + .focus(_.aggregatedSenders) + // bob's aggregation happened later + .modify(_.removed(bob)) + ) + head3.inFlightAggregations shouldBe Map( + aggregationId1 -> inFlightAggregation1 + ) + head4pred.inFlightAggregations shouldBe Map( + aggregationId1 -> inFlightAggregation1, + // aggregationId2 has already expired + aggregationId3 -> inFlightAggregation3, + ) + // All in-flight aggregations have expired + head4.inFlightAggregations shouldBe Map.empty + } + } + } + + "registering a member" should { + "include them in registered members" in { + val store = mk() + + for { + _ <- store.addMember(alice, t1) + state <- store.readAtBlockTimestamp(t1) + } yield { + state.registeredMembers should contain only alice + } + } + + "not immediately give them a head counter as they have no events persisted" in { + val store = mk() + for { + _ <- store.addMember(alice, t1) + state <- store.readAtBlockTimestamp(t1) + } yield { + state.heads.get(alice) should be(None) + } + } + } + + "disabling a member" should { + "be able to disable" in { + val store = mk() + for { + _ <- store.addMember(alice, t1) + state <- store.readAtBlockTimestamp(t1) + enabled1 <- store.isEnabled(alice) + _ <- store.disableMember(alice) + enabled2 <- store.isEnabled(alice) + } yield { + state.registeredMembers should contain only alice + enabled1 shouldBe true + enabled2 shouldBe false + } + } + "disabled member can still be addressed" in { + val store = mk() + for { + _ <- store.addMember(alice, t1) + _ <- store.addMember(bob, t1) + _ <- store.disableMember(bob) + _ <- store.addEvents(Map(bob -> send(bob, SequencerCounter(0), t2, message)), Map.empty) + _ <- store.addEvents(Map(bob -> send(bob, SequencerCounter(1), t3, message)), Map.empty) + state <- store.readAtBlockTimestamp(t3) + } yield state.heads(bob) shouldBe SequencerCounter(1) + } + } + + "add event" should { + "throw an error if a counter is invalid" in withNewTraceContext { implicit traceContext => + val store = mk() + loggerFactory.assertInternalError[IllegalArgumentException]( + store.addEvents(Map(alice -> mockDeliver(-1, t1)), Map.empty), + _.getMessage shouldBe "all counters must be greater or equal to the genesis counter", + ) + Future.successful(succeed) + } + + "throw an error if timestamps are different" in withNewTraceContext { implicit traceContext => + val store = mk() + loggerFactory.assertInternalError[IllegalArgumentException]( + store.addEvents( + Map( + alice -> mockDeliver(0, t1), + bob -> mockDeliver(0, t2), + ), + Map.empty, + ), + _.getMessage shouldBe "events should all be for the same timestamp", + ) + Future.successful(succeed) + } + } + + "read range" should { + "throw argument exception if start and end are incorrect" in { + val store = mk() + loggerFactory.assertInternalError[IllegalArgumentException]( + store.readRange(alice, SequencerCounter(0), SequencerCounter(0)), + _.getMessage shouldBe "startInclusive must be less than endExclusive", + ) + Future.successful(succeed) + } + + "return an empty range if the member is not registered" in { + val store = mk() + + for { + items <- rangeToSeq(store.readRange(alice, SequencerCounter(0), SequencerCounter(1))) + } yield { + items shouldBe empty + } + } + + "return an empty range if a registered member has no events" in { + val store = mk() + + for { + _ <- store.addMember(alice, t1) + items <- rangeToSeq(store.readRange(alice, SequencerCounter(0), SequencerCounter(1))) + } yield { + items shouldBe empty + } + } + + "replay events correctly" in { + val store = mk() + val trafficStateAlice = TrafficState( + NonNegativeLong.tryCreate(5L), + NonNegativeLong.tryCreate(6L), + NonNegativeLong.tryCreate(7L), + t1, + ) + val trafficStateAlice2 = + trafficStateAlice.copy( + extraTrafficRemainder = NonNegativeLong.tryCreate(64L), + timestamp = t2, + ) + for { + _ <- store.addMember(alice, t1) + _ <- store.addEvents( + Map( + alice -> send( + alice, + SequencerCounter(0), + t1, + message, + Some(trafficStateAlice.toSequencedEventTrafficState), + ) + ), + Map(alice -> trafficStateAlice), + ) + _ <- store.addEvents( + Map(alice -> mockDeliver(1, t2, Some(trafficStateAlice2.toSequencedEventTrafficState))), + Map(alice -> trafficStateAlice2), + ) + items <- rangeToSeq(store.readRange(alice, SequencerCounter(0), SequencerCounter(2))) + } yield { + + items.flatMap(_.trafficState) should contain theSameElementsInOrderAs Seq( + trafficStateAlice.toSequencedEventTrafficState, + trafficStateAlice2.toSequencedEventTrafficState, + ) + + items.map(e => e.signedEvent.content) should contain theSameElementsInOrderAs Seq( + send(alice, SequencerCounter(0), t1, message).signedEvent.content, + mockDeliver(1, t2).signedEvent.content, + ) + } + } + } + + "acknowledgements" should { + def acknowledgements( + status: InternalSequencerPruningStatus + ): Map[Member, Option[CantonTimestamp]] = + status.members.map { case SequencerMemberStatus(member, _, lastAcknowledged, _) => + member -> lastAcknowledged + }.toMap + + "latestAcknowledgements should return acknowledgements" in { + val store = mk() + + for { + _ <- store.addMember(alice, t1) + _ <- store.addMember(bob, t2) + _ <- store.acknowledge(alice, t3) + latestAcknowledgements <- store.latestAcknowledgements() + } yield { + latestAcknowledgements shouldBe Map(alice -> t3) + } + } + "acknowledge should ignore earlier timestamps" in { + val store = mk() + + for { + _ <- store.addMember(alice, t1) + _ <- store.acknowledge(alice, t3) + _ <- store.acknowledge(alice, t2) + acknowledgements <- store.status().map(acknowledgements) + } yield acknowledgements shouldBe Map( + alice -> Some(t3) + ) + } + } + + "lower bound" should { + "initially be empty" in { + val store = mk() + + for { + boundO <- store.fetchLowerBound() + } yield boundO shouldBe empty + } + + "return value once saved" in { + val store = mk() + + for { + _ <- store.saveLowerBound(t1).valueOrFail("saveLowerBound") + _ <- store.saveLowerBound(t2).valueOrFail("saveLowerBound") + fetchedBoundO <- store.fetchLowerBound() + } yield fetchedBoundO.value shouldBe t2 + } + + "error if set bound is lower than previous bound but not if it is the same" in { + val store = mk() + val bound1 = CantonTimestamp.Epoch.plusSeconds(10) + val bound2 = bound1.plusMillis(-1) // before prior bound + + for { + fetchedBoundO <- store.fetchLowerBound() + _ = fetchedBoundO shouldBe None + + _ <- store.saveLowerBound(bound1).valueOrFail("saveLowerBound1") + _ <- store.saveLowerBound(bound1).valueOrFail("saveLowerBound2") + error <- leftOrFail(store.saveLowerBound(bound2))("saveLowerBound3") + } yield { + error shouldBe BoundLowerThanExisting(bound1, bound2) + } + } + } + "pruning" should { + "if data has been acknowledged and watermarked remove some now unnecessary data" in { + val store = mk() + val now = ts(10) + for { + _ <- store.addMember(alice, t1) + _ <- store.addEvents( + Map(alice -> send(alice, SequencerCounter(0), t2, message)), + Map.empty, + ) + _ <- store.addMember(bob, t3) + _ <- store.addEvents( + Map( + alice -> mockDeliver(1, ts(5)), + bob -> mockDeliver(0, ts(5)), + ), + Map.empty, + ) + _ <- store.addEvents( + Map( + alice -> mockDeliver(2, ts(6)), + bob -> mockDeliver(1, ts(6)), + ), + Map.empty, + ) + _ <- store.acknowledge(alice, ts(6)) + _ <- store.acknowledge(bob, ts(6)) + statusBefore <- store.status() + pruningTimestamp = statusBefore.safePruningTimestampFor(now) + eventCountBefore <- store.numberOfEvents() + result <- { + logger.debug(s"Pruning sequencer state manager store from $pruningTimestamp") + store.prune(pruningTimestamp) + } + eventCountAfter <- store.numberOfEvents() + statusAfter <- store.status() + lowerBound <- store.fetchLowerBound() + } yield { + result.eventsPruned shouldBe 3L + val eventsRemoved = eventCountBefore - eventCountAfter + eventsRemoved shouldBe 3L + statusBefore.lowerBound shouldBe <(statusAfter.lowerBound) + lowerBound.value shouldBe ts(6) // to prevent reads from before this point + result.newMinimumCountersSupported shouldBe Map( + alice -> SequencerCounter(2), + bob -> SequencerCounter(1), + ) + } + } + "not worry about ignored members" in { + val store = mk() + for { + _ <- store.addMember(alice, t1) + _ <- store.addMember(bob, t2) + _ <- store.addEvents(Map(alice -> mockDeliver(3, ts(3))), Map.empty) + _ <- store.addEvents(Map(bob -> mockDeliver(5, ts(4))), Map.empty) + // clients have acknowledgements at different points + _ <- store.acknowledge(alice, ts(3)) + _ <- store.acknowledge(bob, ts(4)) + _ <- store.disableMember(alice) + status <- store.status() + safeTimestamp = status.safePruningTimestampFor(ts(10)) + result <- { + logger.debug(s"Pruning sequencer state manager store from $safeTimestamp") + store.prune(safeTimestamp) + } + } yield { + safeTimestamp shouldBe ts(4) // as alice is ignored + result.eventsPruned shouldBe 1L + result.newMinimumCountersSupported shouldBe Map(bob -> SequencerCounter(5)) + } + } + } + + "aggregation expiry" should { + "delete all aggregation whose max sequencing time has elapsed" in { + val store = mk() + val aggregationId1 = AggregationId(TestHash.digest(1)) + val aggregationId2 = AggregationId(TestHash.digest(2)) + val rule = AggregationRule( + NonEmpty(Seq, alice, bob, carlos), + threshold = PositiveInt.tryCreate(2), + testedProtocolVersion, + ) + val signatureAlice1 = SymbolicCrypto.signature( + ByteString.copyFromUtf8("signatureAlice1"), + alice.uid.namespace.fingerprint, + ) + val signatureAlice2 = SymbolicCrypto.signature( + ByteString.copyFromUtf8("signatureAlice2"), + alice.uid.namespace.fingerprint, + ) + val signatureAlice3 = SymbolicCrypto.signature( + ByteString.copyFromUtf8("signatureAlice3"), + alice.uid.namespace.fingerprint, + ) + val signatureBob = SymbolicCrypto.signature( + ByteString.copyFromUtf8("signatureBob"), + bob.uid.namespace.fingerprint, + ) + + val inFlightAggregation1 = InFlightAggregation( + rule, + t3, + alice -> AggregationBySender( + t1, + Seq(Seq(signatureAlice1), Seq(signatureAlice2, signatureAlice3)), + ), + bob -> AggregationBySender(t2, Seq(Seq(signatureBob), Seq.empty)), + ) + val inFlightAggregation2 = InFlightAggregation( + rule, + t3.immediateSuccessor, + alice -> AggregationBySender(t2, Seq.fill(3)(Seq.empty)), + ) + + for { + _ <- store.addMember(alice, t1) + _ <- store.addMember(bob, t1) + _ <- store.addInFlightAggregationUpdates( + Map( + aggregationId1 -> inFlightAggregation1.asUpdate, + aggregationId2 -> inFlightAggregation2.asUpdate, + ) + ) + _ <- store.pruneExpiredInFlightAggregations(t2) + head2 <- store.readAtBlockTimestamp(t2) + _ <- store.pruneExpiredInFlightAggregations(t3) + head3 <- store.readAtBlockTimestamp(t3) + // We're using here the ability to read actually inconsistent data (for crash recovery) + // for an already expired block to check that the expiry actually deletes the data. + head2expired <- store.readAtBlockTimestamp(t2) + } yield { + head2.inFlightAggregations shouldBe Map( + aggregationId1 -> inFlightAggregation1, + aggregationId2 -> inFlightAggregation2, + ) + head3.inFlightAggregations shouldBe Map( + aggregationId2 -> inFlightAggregation2 + ) + head2expired.inFlightAggregations shouldBe Map( + aggregationId2 -> inFlightAggregation2 + ) + } + } + } + + "initial topology timestamp" should { + "not be set if not specified initially" in { + val store = mk() + for { + tsEmptyStore <- store.getInitialTopologySnapshotTimestamp + _ <- store.saveLowerBound(t1).valueOrFail("saveLowerBound1") + tsTopologyTimestampNotSetInitially <- store.getInitialTopologySnapshotTimestamp + // Now attempt to set an initial onboarding topology timestamp which should be ignored + _ <- store.saveLowerBound(t2, t1.some).valueOrFail("saveLowerBound2") + tsTopologyIgnoredOnSubsequentCalls <- store.getInitialTopologySnapshotTimestamp + } yield { + tsEmptyStore shouldBe None + tsTopologyTimestampNotSetInitially shouldBe None + tsTopologyIgnoredOnSubsequentCalls shouldBe None + } + } + + "be set initially and not over-writable thereafter" in { + val store = mk() + for { + tsEmptyStore <- store.getInitialTopologySnapshotTimestamp + _ <- store.saveLowerBound(t1, t1.some).valueOrFail("saveLowerBound1") + tsTopologyTimestampSetInitially <- store.getInitialTopologySnapshotTimestamp + // Now attempt to set an initial onboarding topology timestamp which should be ignored + _ <- store.saveLowerBound(t2, t2.some).valueOrFail("saveLowerBound2") + tsTopologyIgnoredOnSubsequentCalls <- store.getInitialTopologySnapshotTimestamp + } yield { + tsEmptyStore shouldBe None + tsTopologyTimestampSetInitially shouldBe t1.some + tsTopologyIgnoredOnSubsequentCalls shouldBe t1.some + } + } + } + + "handle tombstones" should { + "persist and retrieve a tombstone" in { + val store = mk() + for { + _ <- store.addMember(alice, t1) + _ <- store.addEvents( + Map(alice -> mockTombstone(SequencerCounter(1), ts(3))), + Map.empty, + ) + eventOrTombstone <- rangeToSeq( + store.readRange(alice, SequencerCounter(1), SequencerCounter(2)) + ) + } yield { + eventOrTombstone.size shouldBe 1 + eventOrTombstone.head.signedEvent.content match { + case error: DeliverError => + error.timestamp shouldBe ts(3) + error.counter shouldBe SequencerCounter(1) + error.reason.message should include("Sequencer signing key not available") + case event => + fail(s"Expected tombstone, but got ${event}") + } + } + } + } + + def send( + recipient: Member, + counter: SequencerCounter, + ts: CantonTimestamp, + message: ByteString, + trafficState: Option[SequencedEventTrafficState] = None, + ): OrdinarySerializedEvent = + OrdinarySequencedEvent( + SignedContent( + Deliver.create( + counter, + ts, + domainId, + Some(MessageId.tryCreate(s"$recipient-$counter")), + Batch( + List( + ClosedEnvelope + .create(message, Recipients.cc(recipient), Seq.empty, testedProtocolVersion) + ), + testedProtocolVersion, + ), + testedProtocolVersion, + ), + signature, + None, + testedProtocolVersion, + ), + trafficState, + )(TraceContext.empty) + + def mockDeliver( + sc: Long, + ts: CantonTimestamp, + trafficState: Option[SequencedEventTrafficState] = None, + ): OrdinarySerializedEvent = + OrdinarySequencedEvent( + SignedContent( + SequencerTestUtils.mockDeliver(sc, ts, domainId), + signature, + None, + testedProtocolVersion, + ), + trafficState, + )(TraceContext.empty) + + def mockTombstone( + sc: SequencerCounter, + ts: CantonTimestamp, + ): OrdinarySerializedEvent = + OrdinarySequencedEvent( + SignedContent( + DeliverError.create( + sc, + ts, + domainId, + MessageId(String73.tryCreate("tombstone")), + SequencerErrors.PersistTombstone(ts, sc), + testedProtocolVersion, + ), + signature, + None, + testedProtocolVersion, + ), + None, + )(TraceContext.empty) + + def rangeToSeq( + range: Source[OrdinarySerializedEvent, NotUsed] + ): Future[Seq[OrdinarySerializedEvent]] = + range.runWith(Sink.seq)(this.materializer) + } +} diff --git a/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/integrations/state/SequencerStateManagerStoreTestInMemory.scala b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/integrations/state/SequencerStateManagerStoreTestInMemory.scala new file mode 100644 index 0000000000..5ee09d52f7 --- /dev/null +++ b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/integrations/state/SequencerStateManagerStoreTestInMemory.scala @@ -0,0 +1,12 @@ +// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.domain.sequencing.integrations.state + +class SequencerStateManagerStoreTestInMemory extends SequencerStateManagerStoreTest { + "InMemorySequencerStateManagerStore" should { + behave like sequencerStateManagerStore(() => + new InMemorySequencerStateManagerStore(loggerFactory) + ) + } +} diff --git a/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/sequencer/store/SequencerDomainConfigurationStoreTest.scala b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/sequencer/store/SequencerDomainConfigurationStoreTest.scala new file mode 100644 index 0000000000..cd2bfb04c5 --- /dev/null +++ b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/sequencer/store/SequencerDomainConfigurationStoreTest.scala @@ -0,0 +1,107 @@ +// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.domain.sequencing.sequencer.store + +import com.daml.nameof.NameOf.functionFullName +import com.digitalasset.canton.BaseTest +import com.digitalasset.canton.resource.DbStorage +import com.digitalasset.canton.store.db.{DbTest, H2Test, PostgresTest} +import com.digitalasset.canton.topology.DefaultTestIdentities +import monocle.macros.syntax.lens.* +import org.scalatest.wordspec.{AsyncWordSpec, AsyncWordSpecLike} + +import scala.concurrent.Future + +trait SequencerDomainConfigurationStoreTest { + this: AsyncWordSpecLike with BaseTest => + + def domainConfigurationStore(mkStore: => SequencerDomainConfigurationStore): Unit = { + "returns nothing for an empty store" in { + val store = mkStore + + for { + config <- valueOrFail(store.fetchConfiguration)("fetchConfiguration") + } yield config shouldBe None + } + + "when set returns set value" in { + val store = mkStore + val originalConfig = SequencerDomainConfiguration( + DefaultTestIdentities.domainId, + defaultStaticDomainParameters, + ) + + for { + _ <- valueOrFail(store.saveConfiguration(originalConfig))("saveConfiguration") + persistedConfig <- valueOrFail(store.fetchConfiguration)("fetchConfiguration").map(_.value) + } yield persistedConfig shouldBe originalConfig + } + + "supports updating the config" in { + val store = mkStore + val defaultParams = defaultStaticDomainParameters + val originalConfig = SequencerDomainConfiguration( + DefaultTestIdentities.domainId, + defaultParams, + ) + originalConfig.domainParameters.uniqueContractKeys shouldBe false + val updatedConfig = originalConfig + .focus(_.domainParameters) + .replace( + BaseTest.defaultStaticDomainParametersWith( + uniqueContractKeys = true + ) + ) + + for { + _ <- valueOrFail(store.saveConfiguration(originalConfig))("save original config") + persistedConfig1 <- valueOrFail(store.fetchConfiguration)("fetch original config") + .map(_.value) + _ = persistedConfig1 shouldBe originalConfig + _ <- valueOrFail(store.saveConfiguration(updatedConfig))("save updated config") + persistedConfig2 <- valueOrFail(store.fetchConfiguration)("fetch updated config") + .map(_.value) + } yield persistedConfig2 shouldBe updatedConfig + + } + } +} + +class SequencerDomainConfigurationStoreTestInMemory + extends AsyncWordSpec + with BaseTest + with SequencerDomainConfigurationStoreTest { + + behave like domainConfigurationStore(new InMemorySequencerDomainConfigurationStore()) +} + +trait DbSequencerDomainConfigurationStoreTest + extends AsyncWordSpec + with BaseTest + with SequencerDomainConfigurationStoreTest { + this: DbTest => + + override def cleanDb(storage: DbStorage): Future[Unit] = { + import storage.api.* + storage.update( + DBIO.seq( + sqlu"truncate table sequencer_domain_configuration" + ), + functionFullName, + ) + } + + behave like domainConfigurationStore( + new DbSequencerDomainConfigurationStore(storage, timeouts, loggerFactory) + ) + +} + +class SequencerDomainConfigurationStoreTestPostgres + extends DbSequencerDomainConfigurationStoreTest + with PostgresTest + +class SequencerDomainConfigurationStoreTestH2 + extends DbSequencerDomainConfigurationStoreTest + with H2Test diff --git a/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/service/GrpcSequencerInitializationServiceTest.scala b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/service/GrpcSequencerInitializationServiceTest.scala new file mode 100644 index 0000000000..fe117f4ed2 --- /dev/null +++ b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/service/GrpcSequencerInitializationServiceTest.scala @@ -0,0 +1,57 @@ +// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.domain.sequencing.service + +import cats.data.EitherT +import com.digitalasset.canton.BaseTest +import com.digitalasset.canton.crypto.provider.symbolic.SymbolicCrypto +import com.digitalasset.canton.domain.admin.{v0, v2} +import com.digitalasset.canton.domain.sequencing.admin.grpc.{ + InitializeSequencerRequest, + InitializeSequencerResponse, +} +import com.digitalasset.canton.lifecycle.FutureUnlessShutdown +import com.digitalasset.canton.topology.DefaultTestIdentities +import com.digitalasset.canton.topology.store.StoredTopologyTransactions +import com.digitalasset.canton.tracing.Traced +import com.google.protobuf.ByteString +import org.scalatest.wordspec.AsyncWordSpec + +class GrpcSequencerInitializationServiceTest extends AsyncWordSpec with BaseTest { + private val domainId = DefaultTestIdentities.domainId + private val sequencerKey = SymbolicCrypto.signingPublicKey("seq-key") + + def createSut( + initialize: Traced[InitializeSequencerRequest] => EitherT[ + FutureUnlessShutdown, + String, + InitializeSequencerResponse, + ] + ) = + new GrpcSequencerInitializationService(initialize, loggerFactory) + + "GrpcSequencerInitializationService" should { + "call given initialize function (v2) " in { + val initRequest = + v2.InitRequest( + domainId = domainId.toProtoPrimitive, + topologySnapshot = Some(StoredTopologyTransactions.empty.toProtoV0), + domainParameters = Some(defaultStaticDomainParameters.toProtoV1), + snapshot = ByteString.EMPTY, + ) + + val sut = + createSut(_ => + EitherT.rightT[FutureUnlessShutdown, String]( + InitializeSequencerResponse("test", sequencerKey, false) + ) + ) + for { + response <- sut.initV2(initRequest) + } yield { + response shouldBe v0.InitResponse("test", Some(sequencerKey.toProtoV0), false) + } + } + } +} diff --git a/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/EnterpriseSequencerRateLimitManagerTest.scala b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/EnterpriseSequencerRateLimitManagerTest.scala new file mode 100644 index 0000000000..277445d148 --- /dev/null +++ b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/EnterpriseSequencerRateLimitManagerTest.scala @@ -0,0 +1,425 @@ +// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.domain.sequencing.traffic + +import com.digitalasset.canton.config.RequireTypes.{NonNegativeLong, PositiveInt, PositiveLong} +import com.digitalasset.canton.data.CantonTimestamp +import com.digitalasset.canton.domain.metrics.SequencerMetrics +import com.digitalasset.canton.domain.sequencing.sequencer.traffic.SequencerRateLimitError.AboveTrafficLimit +import com.digitalasset.canton.domain.sequencing.sequencer.traffic.SequencerRateLimitManager +import com.digitalasset.canton.domain.sequencing.traffic.store.TrafficLimitsStore +import com.digitalasset.canton.logging.NamedLoggerFactory +import com.digitalasset.canton.sequencing.TrafficControlParameters +import com.digitalasset.canton.sequencing.protocol.* +import com.digitalasset.canton.topology.DefaultTestIdentities.* +import com.digitalasset.canton.topology.Member +import com.digitalasset.canton.tracing.TraceContext +import com.digitalasset.canton.traffic.{EventCostCalculator, TopUpEvent} +import com.digitalasset.canton.{BaseTest, HasExecutionContext} +import com.google.protobuf.ByteString +import org.scalatest.FutureOutcome +import org.scalatest.flatspec.FixtureAsyncFlatSpec + +import scala.concurrent.{ExecutionContext, Future} + +class EnterpriseSequencerRateLimitManagerTest + extends FixtureAsyncFlatSpec + with BaseTest + with HasExecutionContext { + + behavior of "EnterpriseSequencerRateLimiter" + + private val trafficConfig: TrafficControlParameters = TrafficControlParameters() + private val sender: Member = mediatorIdX.member + private val recipients: Recipients = Recipients.cc(participant1, participant2) + private val envelope1: ClosedEnvelope = ClosedEnvelope.create( + ByteString.copyFromUtf8("hello"), + recipients, + Seq.empty, + testedProtocolVersion, + ) + private val eventCost = 5L + private val eventCostCalculator = mock[EventCostCalculator] + private val batch: Batch[ClosedEnvelope] = Batch(List(envelope1), testedProtocolVersion) + private val topUp: TopUpEvent = TopUpEvent( + PositiveLong.tryCreate(10L), + CantonTimestamp.Epoch, + PositiveInt.one, + ) + when( + eventCostCalculator.computeEventCost(batch, trafficConfig.readVsWriteScalingFactor, Map.empty) + ) + .thenReturn(NonNegativeLong.tryCreate(eventCost)) + private val sequencingTs = CantonTimestamp.Epoch.plusSeconds(1) + private val someState = TrafficState + .empty(sequencingTs) + .copy(extraTrafficRemainder = + NonNegativeLong.tryCreate(15L) + ) // value is irrelevant, just different from inState + private val inState = TrafficState.empty(CantonTimestamp.Epoch) + private val sequencerMetrics = SequencerMetrics.noop("sequencer-rate-limit-manager-test") + + case class Env( + trafficConfig: TrafficControlParameters, + batch: Batch[ClosedEnvelope], + rlm: SequencerRateLimitManager, + srlm: SequencerMemberRateLimiter, + trafficLimitsStore: TrafficLimitsStore, + srlmFact: SequencerMemberRateLimiterFactory, + ) + + override type FixtureParam = Env + + it should "return new state if consume is successful" in { implicit f => + val outState = Right(someState) + mockConsumeResponse(outState, None) + mockGetLimitsStoreResponse() + + mockGetLimitsStoreResponse() + + f.rlm + .consume( + sender, + f.batch, + sequencingTs, + inState, + trafficConfig, + Map.empty, + ) + .value + .map { state => + verifyNoPruning(f) + state shouldBe outState + } + } + + it should "initialize new members with the top ups from the store" in { implicit f => + val outState = Right(someState) + mockConsumeResponse(outState, None) + mockGetLimitsStoreResponse() + + mockGetLimitsStoreResponse() + + f.rlm + .consume( + sender, + f.batch, + sequencingTs, + inState, + trafficConfig, + Map.empty, + ) + .value + .map { state => + verifyNoPruning(f) + verify(f.srlmFact, times(1)).create( + sender, + Seq(topUp), + loggerFactory, + sequencerMetrics, + eventCostCalculator, + ) + state shouldBe outState + } + } + + it should "return AboveTrafficLimit with new state" in { implicit f => + val outState = + Left( + AboveTrafficLimit( + sender, + NonNegativeLong.tryCreate(eventCost), + Some(someState), + ) + ) + + mockConsumeResponse(outState, None) + mockGetLimitsStoreResponse() + + f.rlm + .consume( + sender, + f.batch, + sequencingTs, + inState, + trafficConfig, + Map.empty, + ) + .value + .map { state => + verifyNoPruning(f) + state shouldBe outState + } + } + + it should "return traffic status for a member" in { implicit f => + mockGetLimitsStoreResponse(ts = inState.timestamp) + when(f.srlm.getTrafficLimit(inState.timestamp)).thenReturn(NonNegativeLong.tryCreate(50L)) + val topUps = List(topUp, topUp.copy(limit = PositiveLong.tryCreate(56L))) + when(f.srlm.pruneUntilAndGetAllTopUpsFor(inState.timestamp)).thenReturn(topUps) + + f.rlm + .getTrafficStatusFor(Map(sender -> inState)) + .map { status => + status should have size 1 + status.head.timestamp shouldBe inState.timestamp + status.head.member shouldBe sender + status.head.trafficState.extraTrafficRemainder.value shouldBe 50L + status.head.trafficState.extraTrafficLimit.value.value shouldBe 50L + status.head.trafficState.extraTrafficConsumed.value shouldBe 0 + status.head.currentAndFutureTopUps should contain theSameElementsInOrderAs topUps + } + } + + it should "create new traffic state" in { implicit f => + mockGetLimitsStoreResponse() + when(f.srlm.getTrafficLimit(sequencingTs)).thenReturn(NonNegativeLong.tryCreate(54L)) + + f.rlm + .createNewTrafficStateAt(sender, sequencingTs, trafficConfig) + .map { state => + state.timestamp shouldBe sequencingTs + state.extraTrafficConsumed.value shouldBe 0L + state.extraTrafficRemainder.value shouldBe 54L + state.baseTrafficRemainder.value shouldBe trafficConfig.maxBaseTrafficAmount.value + } + } + + it should "top up a member" in { implicit f => + val ts = sequencingTs.plusSeconds(1) + val newTopUp = + TopUpEvent(PositiveLong.tryCreate(152L), ts, PositiveInt.tryCreate(6)) + mockGetLimitsStoreResponse(ts = ts) + doNothing.when(f.srlm).topUp(newTopUp) + when(f.trafficLimitsStore.updateTotalExtraTrafficLimit(sender, newTopUp)) + .thenReturn(Future.unit) + + f.rlm + .topUp(sender, newTopUp) + .map { _ => + assert(true) + } + } + + it should "update traffic states" in { implicit f => + val updateTimestamp = sequencingTs.plusSeconds(1) + + // Create a new rate limiter mock for P1 + val srlmP1 = mock[SequencerMemberRateLimiter] + when( + f.srlmFact.create( + argThat({ (member: Member) => member == participant1 }), + any[Seq[TopUpEvent]], + any[NamedLoggerFactory], + any[SequencerMetrics], + any[EventCostCalculator], + ) + ) + .thenReturn(srlmP1) + + // Will return top up for sender + mockGetLimitsStoreResponse(ts = updateTimestamp) + mockGetLimitsStoreResponse(member = participant1, ts = updateTimestamp) + + when(f.srlm.getTrafficLimit(updateTimestamp)) + .thenReturn(NonNegativeLong.tryCreate(83L)) + + when(srlmP1.getTrafficLimit(updateTimestamp)) + .thenReturn(NonNegativeLong.tryCreate(49L)) + + val ts1 = TrafficState( + NonNegativeLong.tryCreate(10L), + NonNegativeLong.tryCreate(11L), + NonNegativeLong.tryCreate(12L), + CantonTimestamp.now(), + ) + val ts2 = TrafficState( + NonNegativeLong.tryCreate(20L), + NonNegativeLong.tryCreate(21L), + NonNegativeLong.tryCreate(22L), + CantonTimestamp.now(), + ) + val ts1Updated = TrafficState( + NonNegativeLong.tryCreate(30L), + NonNegativeLong.tryCreate(31L), + NonNegativeLong.tryCreate(32L), + CantonTimestamp.now(), + ) + val ts2Updated = TrafficState( + NonNegativeLong.tryCreate(40L), + NonNegativeLong.tryCreate(41L), + NonNegativeLong.tryCreate(42L), + CantonTimestamp.now(), + ) + + when(f.srlm.updateTrafficState(updateTimestamp, trafficConfig, NonNegativeLong.zero, ts1)) + .thenReturn((ts1Updated, true, None)) + + when(srlmP1.updateTrafficState(updateTimestamp, trafficConfig, NonNegativeLong.zero, ts2)) + .thenReturn((ts2Updated, true, None)) + + f.rlm + .updateTrafficStates( + Map( + sender -> ts1, + participant1 -> ts2, + ), + updateTimestamp, + trafficConfig, + ) + .map { newStates => + val newSenderState = newStates.get(sender).value + val newParticipantState = newStates.get(participant1).value + + newSenderState shouldBe ts1Updated + newParticipantState shouldBe ts2Updated + } + } + + it should "cache member top up state in memory" in { implicit f => + val outState = Right(someState) + mockConsumeResponse(outState, None) + mockGetLimitsStoreResponse() + + mockGetLimitsStoreResponse() + + for { + _ <- f.rlm + .consume( + sender, + f.batch, + sequencingTs, + inState, + trafficConfig, + Map.empty, + ) + .value + _ = verify(f.trafficLimitsStore, times(1)) + .getExtraTrafficLimits(sender)( + implicitly[ExecutionContext], + implicitly[TraceContext], + ) + _ <- f.rlm + .consume( + sender, + f.batch, + sequencingTs, + inState, + trafficConfig, + Map.empty, + ) + .value + _ = verifyNoMoreInteractions(f.trafficLimitsStore) + } yield assert(true) + } + + it should "prune traffic limits store when a new top up becomes active" in { implicit f => + val sequencingTs = CantonTimestamp.Epoch.plusSeconds(1) + val inState = TrafficState.empty(CantonTimestamp.Epoch) + val outState = Right(someState) + // Only the sequencer counter matters + val prunableTopUpThreshold = TopUpEvent( + PositiveLong.tryCreate(1), + CantonTimestamp.Epoch, + PositiveInt.tryCreate(5), + ) + when( + f.trafficLimitsStore.pruneBelowSerial(sender, PositiveInt.tryCreate(5))( + implicitly[ExecutionContext], + implicitly[TraceContext], + ) + ).thenReturn(Future.unit) + + mockConsumeResponse(outState, Some(prunableTopUpThreshold)) + mockGetLimitsStoreResponse() + + f.rlm + .consume( + sender, + f.batch, + sequencingTs, + inState, + trafficConfig, + Map.empty, + ) + .value + .map { state => + eventually() { + verify(f.trafficLimitsStore, times(1)).pruneBelowSerial(sender, PositiveInt.tryCreate(5))( + implicitly[ExecutionContext], + implicitly[TraceContext], + ) + () + } + state shouldBe outState + } + } + + private def mockConsumeResponse( + response: Either[AboveTrafficLimit, TrafficState], + newTopUp: Option[TopUpEvent], + )(implicit f: Env) = { + when(f.srlm.tryConsume(f.batch, sequencingTs, f.trafficConfig, inState, Map.empty, sender)) + .thenReturn(response -> newTopUp) + } + + private def mockGetLimitsStoreResponse( + topUps: Seq[TopUpEvent] = Seq(topUp), + ts: CantonTimestamp = sequencingTs, + member: Member = sender, + )(implicit f: Env): Unit = { + when( + f.trafficLimitsStore + .getExtraTrafficLimits(member)( + implicitly[ExecutionContext], + implicitly[TraceContext], + ) + ).thenReturn(Future.successful(topUps)) + () + } + + private def verifyNoPruning(implicit f: Env): Unit = { + verify(f.trafficLimitsStore, times(0)).pruneBelowSerial( + any[Member], + any[PositiveInt], + )(any[ExecutionContext], any[TraceContext]) + () + } + + override def withFixture(test: OneArgAsyncTest): FutureOutcome = { + val trafficLimitsStore: TrafficLimitsStore = mock[TrafficLimitsStore] + val srlm: SequencerMemberRateLimiter = mock[SequencerMemberRateLimiter] + val srlmFact = mock[SequencerMemberRateLimiterFactory] + when( + srlmFact.create( + argThat({ (member: Member) => member == sender }), + any[Seq[TopUpEvent]], + any[NamedLoggerFactory], + any[SequencerMetrics], + any[EventCostCalculator], + ) + ).thenReturn(srlm) + + val rlm: EnterpriseSequencerRateLimitManager = new EnterpriseSequencerRateLimitManager( + trafficLimitsStore, + loggerFactory, + futureSupervisor, + timeouts, + sequencerMetrics, + srlmFact, + eventCostCalculator, + ) + + val env = Env( + trafficConfig, + batch, + rlm, + srlm, + trafficLimitsStore, + srlmFact, + ) + + withFixture(test.toNoArgAsyncTest(env)) + } +} diff --git a/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/EventCostCalculatorTest.scala b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/EventCostCalculatorTest.scala new file mode 100644 index 0000000000..5866e283a3 --- /dev/null +++ b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/EventCostCalculatorTest.scala @@ -0,0 +1,48 @@ +// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.domain.sequencing.traffic + +import com.digitalasset.canton.config.RequireTypes.PositiveInt +import com.digitalasset.canton.sequencing.protocol.{AllMembersOfDomain, ClosedEnvelope, Recipients} +import com.digitalasset.canton.topology.Member +import com.digitalasset.canton.traffic.EventCostCalculator +import com.digitalasset.canton.{BaseTest, ProtocolVersionChecksAnyWordSpec} +import com.google.protobuf.ByteString +import org.scalatest.wordspec.AnyWordSpec + +class EventCostCalculatorTest + extends AnyWordSpec + with BaseTest + with ProtocolVersionChecksAnyWordSpec { + private val recipient1 = mock[Member] + private val recipient2 = mock[Member] + + "calculate cost correctly" in { + new EventCostCalculator().computeEnvelopeCost( + PositiveInt.tryCreate(5000), + Map.empty, + )( + ClosedEnvelope.create( + ByteString.copyFrom(Array.fill(5)(1.toByte)), + Recipients.cc(recipient1, recipient2), + Seq.empty, + testedProtocolVersion, + ) + ) shouldBe 10L // == 5 + 5 * 2 * 5000 / 10000 + } + + "use resolved group recipients" in { + new EventCostCalculator().computeEnvelopeCost( + PositiveInt.tryCreate(5000), + Map(AllMembersOfDomain -> Set(recipient1, recipient2)), + )( + ClosedEnvelope.create( + ByteString.copyFrom(Array.fill(5)(1.toByte)), + Recipients.cc(AllMembersOfDomain), + Seq.empty, + testedProtocolVersion, + ) + ) shouldBe 10L // == 5 + 5 * 2 * 5000 / 10000 + } +} diff --git a/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/SequencerMemberRateLimiterTest.scala b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/SequencerMemberRateLimiterTest.scala new file mode 100644 index 0000000000..70788f97fb --- /dev/null +++ b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/SequencerMemberRateLimiterTest.scala @@ -0,0 +1,297 @@ +// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.domain.sequencing.traffic + +import com.daml.metrics.api.MetricName +import com.digitalasset.canton.config.RequireTypes.* +import com.digitalasset.canton.data.CantonTimestamp +import com.digitalasset.canton.domain.metrics.SequencerMetrics +import com.digitalasset.canton.domain.sequencing.sequencer.traffic.SequencerRateLimitError.AboveTrafficLimit +import com.digitalasset.canton.metrics.MetricHandle.NoOpMetricsFactory +import com.digitalasset.canton.metrics.Metrics +import com.digitalasset.canton.sequencing.TrafficControlParameters +import com.digitalasset.canton.sequencing.protocol.* +import com.digitalasset.canton.topology.Member +import com.digitalasset.canton.traffic.{EventCostCalculator, TopUpEvent} +import com.digitalasset.canton.{BaseTest, metrics, time} +import com.google.protobuf.ByteString +import org.scalatest.flatspec.AnyFlatSpec + +class SequencerMemberRateLimiterTest extends AnyFlatSpec with BaseTest { + behavior of "sequencer rate limiter" + + private val recipient1 = mock[Member] + private val recipient2 = mock[Member] + + // Doesn't matter as the event cost function is mocked later + private val mockEvent = makeBatch( + List( + ClosedEnvelope.create( + ByteString.copyFrom(Array.fill(5)(1.toByte)), + Recipients.cc(recipient1, recipient2), + Seq.empty, + testedProtocolVersion, + ) + ) + ) + + private val trafficControlConfig = TrafficControlParameters( + NonNegativeNumeric.tryCreate(200L), + PositiveNumeric.tryCreate(200), + time.NonNegativeFiniteDuration.tryOfSeconds(10), + ) + + private val start = CantonTimestamp.now() + private val sender = mock[Member] + private val eventCostCalculator = mock[EventCostCalculator] + private val sequencerMetrics = new SequencerMetrics( + MetricName("test"), + NoOpMetricsFactory, + Metrics.ForTesting.daml.grpc, + metrics.Metrics.ForTesting.daml.health, + ) + + private def makeBatch(envelopes: List[ClosedEnvelope]) = { + Batch.apply(envelopes, testedProtocolVersion) + } + + private def makeLimiter = { + new SequencerMemberRateLimiter( + sender, + Seq.empty, + loggerFactory, + sequencerMetrics, + eventCostCalculator, + ) + } + + it should "consume event if enough base rate" in { + val rateLimiter = + new SequencerMemberRateLimiter( + sender, + Seq.empty, + loggerFactory, + sequencerMetrics, + eventCostCalculator, + ) + + when( + eventCostCalculator.computeEventCost( + any[Batch[ClosedEnvelope]], + any[PositiveNumeric[Int]], + any[Map[GroupRecipient, Set[Member]]], + ) + ) + .thenAnswer(NonNegativeLong.tryCreate(40)) + + // Base rate = 20 -> 100 bytes allowed after 5 seconds + val eventTimestamp = start.plusSeconds(5) + rateLimiter + .tryConsume( + mockEvent, + eventTimestamp, + trafficControlConfig, + TrafficState.empty(start), + Map.empty, + sender, + ) shouldBe Right( + TrafficState( + extraTrafficRemainder = NonNegativeLong.zero, + extraTrafficConsumed = NonNegativeLong.zero, + baseTrafficRemainder = NonNegativeLong.tryCreate(60), // 100 - 40 + timestamp = eventTimestamp, + ) + ) -> None + } + + it should "reject if not enough base rate" in { + val rateLimiter = makeLimiter + + when( + eventCostCalculator.computeEventCost( + any[Batch[ClosedEnvelope]], + any[PositiveNumeric[Int]], + any[Map[GroupRecipient, Set[Member]]], + ) + ) + .thenAnswer(NonNegativeLong.tryCreate(21)) // just 1 above base rate + + // Base traffic = 20 + val eventTimestamp = start.plusSeconds(1) + rateLimiter + .tryConsume( + mockEvent, + eventTimestamp, + trafficControlConfig, + TrafficState.empty(start), + Map.empty, + sender, + ) shouldBe Left( + AboveTrafficLimit( + sender, + NonNegativeLong.tryCreate(21), + Some( + TrafficState( + NonNegativeLong.zero, + NonNegativeLong.zero, + NonNegativeLong.tryCreate(20), + eventTimestamp, + ) + ), + ) + ) -> None + } + + it should "allow top up" in { + val rateLimiter = makeLimiter + + rateLimiter.topUp( + TopUpEvent(PositiveLong.tryCreate(50L), start, PositiveInt.tryCreate(1)) + ) + rateLimiter.getTrafficLimit(start).value shouldBe 50L + } + + it should "consume only from base rate if enough" in { + val rateLimiter = makeLimiter + + val topUp = + TopUpEvent(PositiveLong.tryCreate(50L), start, PositiveInt.tryCreate(1)) + rateLimiter.topUp(topUp) + rateLimiter.getTrafficLimit(start).value shouldBe 50L + + when( + eventCostCalculator.computeEventCost( + any[Batch[ClosedEnvelope]], + any[PositiveNumeric[Int]], + any[Map[GroupRecipient, Set[Member]]], + ) + ) + .thenAnswer(NonNegativeLong.tryCreate(40)) + + // Base rate = 20 -> 100 bytes allowed after 5 seconds + // Extra limit = 50 + val eventTimestamp = start.plusSeconds(5) + rateLimiter + .tryConsume( + mockEvent, + eventTimestamp, + trafficControlConfig, + TrafficState.empty(start), + Map.empty, + sender, + ) shouldBe Right( + TrafficState( + extraTrafficRemainder = NonNegativeLong.tryCreate(50L), + extraTrafficConsumed = NonNegativeLong.zero, + baseTrafficRemainder = NonNegativeLong.tryCreate(60), // 100 - 40 + timestamp = eventTimestamp, + ) + ) -> Some(topUp) + } + + it should "consume from base rate and extra limit" in { + val rateLimiter = makeLimiter + + rateLimiter.topUp( + TopUpEvent(PositiveLong.tryCreate(50L), start, PositiveInt.tryCreate(1)) + ) + rateLimiter.getTrafficLimit(start).value shouldBe 50L + + when( + eventCostCalculator.computeEventCost( + any[Batch[ClosedEnvelope]], + any[PositiveNumeric[Int]], + any[Map[GroupRecipient, Set[Member]]], + ) + ) + .thenAnswer(NonNegativeLong.tryCreate(30)) + + // Base rate = 20 bytes allowed after 1 second + // Extra limit = 50 + val eventTimestamp = start.plusSeconds(1) + val trafficState1 = rateLimiter + .tryConsume( + mockEvent, + eventTimestamp, + trafficControlConfig, + TrafficState.empty(start), + Map.empty, + sender, + ) + ._1 + .value + trafficState1 shouldBe TrafficState( + extraTrafficRemainder = NonNegativeLong.tryCreate(40L), // Traffic limit (50) - consumed (10) + extraTrafficConsumed = + NonNegativeLong.tryCreate(10L), // because event cost is 30 - 20 from base rate + baseTrafficRemainder = NonNegativeLong.zero, + timestamp = eventTimestamp, + ) + + val eventTimestamp2 = eventTimestamp.plusMillis(1) + rateLimiter + .tryConsume( + mockEvent, + eventTimestamp2, + trafficControlConfig, + trafficState1, + Map.empty, + sender, + ) shouldBe Right( + TrafficState( + extraTrafficRemainder = + NonNegativeLong.tryCreate(10L), // Traffic limit (50) - consumed (40) + extraTrafficConsumed = NonNegativeLong.tryCreate( + 40L + ), // consume full amount from extra limit as no base rate was accumulated + baseTrafficRemainder = NonNegativeLong.zero, + timestamp = eventTimestamp2, + ) + ) -> None + } + + it should "reject if not enough even with extra limit" in { + val rateLimiter = makeLimiter + + val topUp = + TopUpEvent(PositiveLong.tryCreate(50L), start, PositiveInt.tryCreate(1)) + rateLimiter.topUp(topUp) + rateLimiter.getTrafficLimit(start).value shouldBe 50L + + when( + eventCostCalculator.computeEventCost( + any[Batch[ClosedEnvelope]], + any[PositiveNumeric[Int]], + any[Map[GroupRecipient, Set[Member]]], + ) + ) + .thenAnswer(NonNegativeLong.tryCreate(200)) + + // Base rate = 20 -> 100 bytes allowed after 5 seconds + // Extra limit = 50 + val eventTimestamp = start.plusSeconds(5) + rateLimiter + .tryConsume( + mockEvent, + eventTimestamp, + trafficControlConfig, + TrafficState.empty(start), + Map.empty, + sender, + ) shouldBe Left( + AboveTrafficLimit( + sender, + NonNegativeLong.tryCreate(200L), + Some( + TrafficState( + extraTrafficRemainder = NonNegativeLong.tryCreate(50L), + extraTrafficConsumed = NonNegativeLong.zero, + baseTrafficRemainder = NonNegativeLong.tryCreate(100L), + timestamp = eventTimestamp, + ) + ), + ) + ) -> Some(topUp) + } +} diff --git a/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/store/DbTrafficLimitsStoreTest.scala b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/store/DbTrafficLimitsStoreTest.scala new file mode 100644 index 0000000000..834be742f9 --- /dev/null +++ b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/store/DbTrafficLimitsStoreTest.scala @@ -0,0 +1,35 @@ +// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.domain.sequencing.traffic.store + +import com.daml.nameof.NameOf.functionFullName +import com.digitalasset.canton.BaseTest +import com.digitalasset.canton.domain.sequencing.traffic.store.db.DbTrafficLimitsStore +import com.digitalasset.canton.resource.DbStorage +import com.digitalasset.canton.store.db.DbTest +import com.digitalasset.canton.version.ProtocolVersion +import org.scalatest.wordspec.AsyncWordSpec + +import scala.concurrent.Future + +trait DbTrafficLimitsStoreTest extends AsyncWordSpec with BaseTest with TrafficLimitsStoreTest { + this: DbTest => + override def cleanDb(storage: DbStorage): Future[Unit] = { + import storage.api.* + if (testedProtocolVersion >= ProtocolVersion.v30) + storage.update(DBIO.seq(sqlu"truncate table top_up_events"), functionFullName) + else Future.unit + } + + "TrafficLimitsStore" should { + behave like trafficLimitsStore(() => + new DbTrafficLimitsStore( + storage, + testedProtocolVersion, + timeouts, + loggerFactory, + ) + ) + } +} diff --git a/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/store/TrafficLimitsStoreTest.scala b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/store/TrafficLimitsStoreTest.scala new file mode 100644 index 0000000000..ebb7b10809 --- /dev/null +++ b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/store/TrafficLimitsStoreTest.scala @@ -0,0 +1,199 @@ +// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.domain.sequencing.traffic.store + +import com.digitalasset.canton.config.RequireTypes.{PositiveInt, PositiveLong} +import com.digitalasset.canton.data.CantonTimestamp +import com.digitalasset.canton.logging.LogEntry +import com.digitalasset.canton.topology.ParticipantId +import com.digitalasset.canton.traffic.TopUpEvent +import com.digitalasset.canton.{BaseTest, ProtocolVersionChecksAsyncWordSpec} +import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AsyncWordSpec +import org.scalatest.{BeforeAndAfterAll, EitherValues, OptionValues} + +trait TrafficLimitsStoreTest + extends BeforeAndAfterAll + with EitherValues + with BaseTest + with ProtocolVersionChecksAsyncWordSpec { + this: AsyncWordSpec with Matchers with OptionValues => + + def trafficLimitsStore(mk: () => TrafficLimitsStore): Unit = { + val alice = ParticipantId("alice") + val bob = ParticipantId("bob") + val t1 = CantonTimestamp.Epoch + val t2 = t1.plusSeconds(1) + val t3 = t2.plusSeconds(1) + + "updateTotalExtraTrafficLimit" should { + "add top ups" in { + val store = mk() + val topUpEventAlice = TopUpEvent(PositiveLong.tryCreate(5L), t1, PositiveInt.tryCreate(1)) + val topUpEventAlice2 = TopUpEvent(PositiveLong.tryCreate(9L), t2, PositiveInt.tryCreate(2)) + val topUpEventBob = TopUpEvent(PositiveLong.tryCreate(6L), t1, PositiveInt.tryCreate(3)) + for { + _ <- store.updateTotalExtraTrafficLimit( + Map( + alice -> topUpEventAlice, + bob -> topUpEventBob, + ) + ) + _ <- store.updateTotalExtraTrafficLimit( + Map( + alice -> topUpEventAlice2 + ) + ) + aliceEvents <- store.getExtraTrafficLimits(alice) + bobEvents <- store.getExtraTrafficLimits(bob) + } yield { + aliceEvents should contain theSameElementsInOrderAs List( + topUpEventAlice, + topUpEventAlice2, + ) + bobEvents should contain theSameElementsInOrderAs List(topUpEventBob) + } + } + + "be idempotent if inserting the same top up twice" in { + val store = mk() + val topUpEventAlice = TopUpEvent(PositiveLong.tryCreate(5L), t1, PositiveInt.tryCreate(1)) + for { + _ <- store.updateTotalExtraTrafficLimit( + Map( + alice -> topUpEventAlice + ) + ) + _ <- store.updateTotalExtraTrafficLimit( + Map( + alice -> topUpEventAlice + ) + ) + aliceEvents <- store.getExtraTrafficLimits(alice) + } yield { + aliceEvents should contain theSameElementsInOrderAs List( + topUpEventAlice + ) + } + } + + "add top ups with same effective timestamps" in { + val store = mk() + val topUpEventAlice = TopUpEvent(PositiveLong.tryCreate(5L), t1, PositiveInt.tryCreate(1)) + val topUpEventAlice2 = TopUpEvent(PositiveLong.tryCreate(50L), t1, PositiveInt.tryCreate(2)) + for { + _ <- store.updateTotalExtraTrafficLimit( + Map( + alice -> topUpEventAlice2 + ) + ) + _ <- store.updateTotalExtraTrafficLimit( + Map( + alice -> topUpEventAlice + ) + ) + aliceEvents <- store.getExtraTrafficLimits(alice) + } yield { + aliceEvents should contain theSameElementsInOrderAs List( + topUpEventAlice, + topUpEventAlice2, + ) + } + } + + "insert top ups out of order but get them back in the right order" in { + val store = mk() + val topUpEventAlice = TopUpEvent(PositiveLong.tryCreate(5L), t1, PositiveInt.tryCreate(1)) + val topUpEventAlice2 = TopUpEvent(PositiveLong.tryCreate(50L), t2, PositiveInt.tryCreate(2)) + for { + _ <- store.updateTotalExtraTrafficLimit( + Map( + alice -> topUpEventAlice2 + ) + ) + _ <- store.updateTotalExtraTrafficLimit( + Map( + alice -> topUpEventAlice + ) + ) + aliceEvents <- store.getExtraTrafficLimits(alice) + } yield { + aliceEvents should contain theSameElementsInOrderAs List( + topUpEventAlice, + topUpEventAlice2, + ) + } + } + + "fail to insert 2 top ups with the same sequencer counter but different limits" in { + val store = mk() + val topUpEventAlice = TopUpEvent(PositiveLong.tryCreate(5L), t1, PositiveInt.tryCreate(1)) + val topUpEventAlice2 = TopUpEvent(PositiveLong.tryCreate(9L), t2, PositiveInt.tryCreate(1)) + + val res = loggerFactory.assertLoggedWarningsAndErrorsSeq( + { + for { + _ <- store.updateTotalExtraTrafficLimit( + Map( + alice -> topUpEventAlice + ) + ) + _ <- store.updateTotalExtraTrafficLimit( + Map( + alice -> topUpEventAlice2 + ) + ) + } yield () + }, + LogEntry.assertLogSeq( + Seq( + ( + _.errorMessage should include("has existing extra_traffic_limit value of"), + "expected logged DB failure", + ) + ) + ), + ) + recoverToSucceededIf[IllegalStateException](res) + } + } + + "pruneBelowCounter" should { + "remove all events below a given sequencer counter" in { + val store = mk() + val topUpEventAlice = TopUpEvent(PositiveLong.tryCreate(5L), t1, PositiveInt.tryCreate(1)) + val topUpEventAlice2 = TopUpEvent(PositiveLong.tryCreate(9L), t2, PositiveInt.tryCreate(2)) + val topUpEventAlice3 = TopUpEvent(PositiveLong.tryCreate(10L), t3, PositiveInt.tryCreate(3)) + val topUpEventBob = TopUpEvent(PositiveLong.tryCreate(6L), t1, PositiveInt.tryCreate(3)) + for { + _ <- store.updateTotalExtraTrafficLimit( + Map( + alice -> topUpEventAlice, + bob -> topUpEventBob, + ) + ) + _ <- store.updateTotalExtraTrafficLimit( + Map( + alice -> topUpEventAlice2 + ) + ) + _ <- store.updateTotalExtraTrafficLimit( + Map( + alice -> topUpEventAlice3 + ) + ) + _ <- store.pruneBelowSerial(alice, PositiveInt.tryCreate(2)) + aliceEvents <- store.getExtraTrafficLimits(alice) + bobEvents <- store.getExtraTrafficLimits(bob) + } yield { + aliceEvents should contain theSameElementsInOrderAs List( + topUpEventAlice2, + topUpEventAlice3, + ) + bobEvents should contain theSameElementsInOrderAs List(topUpEventBob) + } + } + } + } +} diff --git a/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/store/TrafficLimitsStoreTestInMemory.scala b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/store/TrafficLimitsStoreTestInMemory.scala new file mode 100644 index 0000000000..9f934090a1 --- /dev/null +++ b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/store/TrafficLimitsStoreTestInMemory.scala @@ -0,0 +1,17 @@ +// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.domain.sequencing.traffic.store + +import com.digitalasset.canton.BaseTest +import com.digitalasset.canton.domain.sequencing.traffic.store.memory.InMemoryTrafficLimitsStore +import org.scalatest.wordspec.AsyncWordSpec + +class TrafficLimitsStoreTestInMemory + extends AsyncWordSpec + with BaseTest + with TrafficLimitsStoreTest { + "InMemoryTrafficLimitsStore" should { + behave like trafficLimitsStore(() => new InMemoryTrafficLimitsStore(loggerFactory)) + } +} diff --git a/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/store/TrafficLimitsStoreTestPostgres.scala b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/store/TrafficLimitsStoreTestPostgres.scala new file mode 100644 index 0000000000..6d9345b03b --- /dev/null +++ b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/store/TrafficLimitsStoreTestPostgres.scala @@ -0,0 +1,8 @@ +// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.domain.sequencing.traffic.store + +import com.digitalasset.canton.store.db.PostgresTest + +class TrafficLimitsStoreTestPostgres extends DbTrafficLimitsStoreTest with PostgresTest diff --git a/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/store/TrafficLimitsStoreTesttH2.scala b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/store/TrafficLimitsStoreTesttH2.scala new file mode 100644 index 0000000000..067a88a0ea --- /dev/null +++ b/canton-3x/community/domain/src/test/scala/com/digitalasset/canton/domain/sequencing/traffic/store/TrafficLimitsStoreTesttH2.scala @@ -0,0 +1,8 @@ +// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.domain.sequencing.traffic.store + +import com.digitalasset.canton.store.db.H2Test + +class TrafficLimitsStoreTesttH2 extends DbTrafficLimitsStoreTest with H2Test diff --git a/canton-3x/community/integration-testing/src/main/scala/com/digitalasset/canton/console/ConsoleEnvironmentTestHelpers.scala b/canton-3x/community/integration-testing/src/main/scala/com/digitalasset/canton/console/ConsoleEnvironmentTestHelpers.scala index cbdf256148..afc7ae4082 100644 --- a/canton-3x/community/integration-testing/src/main/scala/com/digitalasset/canton/console/ConsoleEnvironmentTestHelpers.scala +++ b/canton-3x/community/integration-testing/src/main/scala/com/digitalasset/canton/console/ConsoleEnvironmentTestHelpers.scala @@ -57,15 +57,25 @@ trait ConsoleEnvironmentTestHelpers[+CE <: ConsoleEnvironment] { this: CE => .find(_.name == name) .getOrElse(sys.error(s"remote domain [$name] not configured")) - def rmx(name: String): RemoteMediatorReferenceX = - mediatorsX.remote + def sx(name: String): LocalSequencerNodeReferenceX = + sequencersX.local .find(_.name == name) - .getOrElse(sys.error(s"remote mediator-x [$name] not configured")) + .getOrElse(sys.error(s"sequencer-x [$name] not configured")) + + def rsx(name: String): RemoteSequencerNodeReferenceX = + sequencersX.remote + .find(_.name == name) + .getOrElse(sys.error(s"remote sequencer-x [$name] not configured")) def mx(name: String): LocalMediatorReferenceX = mediatorsX.local .find(_.name == name) .getOrElse(sys.error(s"mediator-x [$name] not configured")) + def rmx(name: String): RemoteMediatorReferenceX = + mediatorsX.remote + .find(_.name == name) + .getOrElse(sys.error(s"remote mediator-x [$name] not configured")) + def mediatorIdForDomain(domain: String): MediatorId = MediatorId(d(domain).id) } diff --git a/canton-3x/community/integration-testing/src/main/scala/com/digitalasset/canton/integration/CommonTestAliases.scala b/canton-3x/community/integration-testing/src/main/scala/com/digitalasset/canton/integration/CommonTestAliases.scala index 402864f0b5..efda775fcc 100644 --- a/canton-3x/community/integration-testing/src/main/scala/com/digitalasset/canton/integration/CommonTestAliases.scala +++ b/canton-3x/community/integration-testing/src/main/scala/com/digitalasset/canton/integration/CommonTestAliases.scala @@ -9,8 +9,10 @@ import com.digitalasset.canton.console.{ LocalMediatorReferenceX, LocalParticipantReference, LocalParticipantReferenceX, + LocalSequencerNodeReferenceX, ParticipantReference, ParticipantReferenceX, + RemoteSequencerNodeReferenceX, } /** Aliases used by our typical single domain and multi domain tests. @@ -34,6 +36,14 @@ trait CommonTestAliases[+CE <: ConsoleEnvironment] { lazy val acme: CE#DomainLocalRef = d("acme") lazy val repairDomain: CE#DomainLocalRef = d("repair") + lazy val sequencer1x: LocalSequencerNodeReferenceX = sx("sequencer1") + lazy val sequencer2x: LocalSequencerNodeReferenceX = sx("sequencer2") + lazy val sequencer3x: LocalSequencerNodeReferenceX = sx("sequencer3") + lazy val sequencer4x: LocalSequencerNodeReferenceX = sx("sequencer4") + + // Remote + lazy val remoteSequencer1x: RemoteSequencerNodeReferenceX = rsx("sequencer1") + lazy val mediator1x: LocalMediatorReferenceX = mx("mediator1") lazy val mediator2x: LocalMediatorReferenceX = mx("mediator2") lazy val mediator3x: LocalMediatorReferenceX = mx("mediator3") diff --git a/canton-3x/community/integration-testing/src/main/scala/com/digitalasset/canton/integration/CommunityConfigTransforms.scala b/canton-3x/community/integration-testing/src/main/scala/com/digitalasset/canton/integration/CommunityConfigTransforms.scala index 21a0f9c8a7..a25ad546b2 100644 --- a/canton-3x/community/integration-testing/src/main/scala/com/digitalasset/canton/integration/CommunityConfigTransforms.scala +++ b/canton-3x/community/integration-testing/src/main/scala/com/digitalasset/canton/integration/CommunityConfigTransforms.scala @@ -14,6 +14,7 @@ import com.digitalasset.canton.config.{ } import com.digitalasset.canton.domain.config.CommunityDomainConfig import com.digitalasset.canton.domain.mediator.CommunityMediatorNodeXConfig +import com.digitalasset.canton.domain.sequencing.config.CommunitySequencerNodeXConfig import com.digitalasset.canton.participant.config.CommunityParticipantConfig import com.typesafe.config.{Config, ConfigValueFactory} import monocle.macros.syntax.lens.* @@ -79,6 +80,17 @@ object CommunityConfigTransforms { .focus(_.participants) .modify(_.map { case (pName, pConfig) => (pName, update(pName.unwrap, pConfig)) }) + def updateAllSequencerXConfigs( + update: (String, CommunitySequencerNodeXConfig) => CommunitySequencerNodeXConfig + ): CommunityConfigTransform = + _.focus(_.sequencersX) + .modify(_.map { case (sName, sConfig) => (sName, update(sName.unwrap, sConfig)) }) + + def updateAllSequencerXConfigs_( + update: CommunitySequencerNodeXConfig => CommunitySequencerNodeXConfig + ): CommunityConfigTransform = + updateAllSequencerXConfigs((_, config) => update(config)) + def updateAllMediatorXConfigs_( update: CommunityMediatorNodeXConfig => CommunityMediatorNodeXConfig ): CommunityConfigTransform = @@ -120,6 +132,15 @@ object CommunityConfigTransforms { .replace(nextPort.some) } + val sequencerXUpdate = updateAllSequencerXConfigs_( + _.focus(_.publicApi.internalPort) + .replace(nextPort.some) + .focus(_.adminApi.internalPort) + .replace(nextPort.some) + .focus(_.monitoring.grpcHealthServer) + .modify(_.map(_.copy(internalPort = nextPort.some))) + ) + val mediatorXUpdate = updateAllMediatorXConfigs_( _.focus(_.adminApi.internalPort) .replace(nextPort.some) @@ -127,7 +148,7 @@ object CommunityConfigTransforms { .modify(_.map(_.copy(internalPort = nextPort.some))) ) - domainUpdate compose participantUpdate compose mediatorXUpdate + domainUpdate compose participantUpdate compose sequencerXUpdate compose mediatorXUpdate } } diff --git a/canton-3x/community/integration-testing/src/main/scala/com/digitalasset/canton/integration/CommunityEnvironmentDefinition.scala b/canton-3x/community/integration-testing/src/main/scala/com/digitalasset/canton/integration/CommunityEnvironmentDefinition.scala index 9f66b2b8cd..11a400ec83 100644 --- a/canton-3x/community/integration-testing/src/main/scala/com/digitalasset/canton/integration/CommunityEnvironmentDefinition.scala +++ b/canton-3x/community/integration-testing/src/main/scala/com/digitalasset/canton/integration/CommunityEnvironmentDefinition.scala @@ -4,8 +4,15 @@ package com.digitalasset.canton.integration import better.files.{File, Resource} -import com.digitalasset.canton.config.{CantonCommunityConfig, TestingConfigInternal} +import com.digitalasset.canton.BaseTest +import com.digitalasset.canton.admin.api.client.data.StaticDomainParameters +import com.digitalasset.canton.config.{ + CantonCommunityConfig, + CommunityCryptoConfig, + TestingConfigInternal, +} import com.digitalasset.canton.console.TestConsoleOutput +import com.digitalasset.canton.domain.config.DomainParametersConfig import com.digitalasset.canton.environment.{ CommunityConsoleEnvironment, CommunityEnvironment, @@ -14,6 +21,7 @@ import com.digitalasset.canton.environment.{ } import com.digitalasset.canton.integration.CommunityTests.CommunityTestConsoleEnvironment import com.digitalasset.canton.logging.NamedLoggerFactory +import com.digitalasset.canton.version.DomainProtocolVersion import com.typesafe.config.ConfigFactory import monocle.macros.syntax.lens.* @@ -58,6 +66,17 @@ final case class CommunityEnvironmentDefinition( ) with TestEnvironment[CommunityEnvironment] { override val actualConfig: CantonCommunityConfig = this.environment.config } + + lazy val defaultStaticDomainParametersX: StaticDomainParameters = + StaticDomainParameters.fromConfig( + DomainParametersConfig( + protocolVersion = DomainProtocolVersion(BaseTest.testedProtocolVersion), + devVersionSupport = true, + // TODO(#13235) Remove key uniqueness config + uniqueContractKeys = false, + ), + CommunityCryptoConfig(), + ) } object CommunityEnvironmentDefinition { diff --git a/canton-3x/community/integration-testing/src/main/scala/com/digitalasset/canton/integration/IntegrationTestUtilities.scala b/canton-3x/community/integration-testing/src/main/scala/com/digitalasset/canton/integration/IntegrationTestUtilities.scala index 2b1503ac5f..dd6a746d9d 100644 --- a/canton-3x/community/integration-testing/src/main/scala/com/digitalasset/canton/integration/IntegrationTestUtilities.scala +++ b/canton-3x/community/integration-testing/src/main/scala/com/digitalasset/canton/integration/IntegrationTestUtilities.scala @@ -6,6 +6,7 @@ package com.digitalasset.canton.integration import com.daml.ledger.api.v1.transaction.TreeEvent.Kind.{Created, Exercised} import com.daml.ledger.api.v1.transaction.{TransactionTree, TreeEvent} import com.daml.ledger.api.v1.value.Value +import com.daml.ledger.api.v2.transaction.TransactionTree as TransactionTreeV2 import com.digitalasset.canton.concurrent.Threading import com.digitalasset.canton.console.{ InstanceReference, @@ -13,6 +14,7 @@ import com.digitalasset.canton.console.{ LocalParticipantReference, LocalParticipantReferenceCommon, } +import com.digitalasset.canton.participant.ParticipantNodeCommon import com.digitalasset.canton.participant.admin.inspection.SyncStateInspection import com.digitalasset.canton.participant.sync.{LedgerSyncEvent, TimestampedEvent} import com.digitalasset.canton.tracing.TraceContext @@ -84,9 +86,9 @@ object IntegrationTestUtilities { mkGrabCounts(pcsCount, acceptedTransactionCount, limit) } - def grabCountsX( + def grabCountsX[ParticipantNodeT <: ParticipantNodeCommon]( domain: DomainAlias, - pr: LocalParticipantReferenceCommon, + pr: LocalParticipantReferenceCommon[ParticipantNodeT], limit: Int = 100, ): GrabbedCounts = { val pcsCount = pr.testing.pcs_search(domain, limit = limit).length @@ -102,7 +104,10 @@ object IntegrationTestUtilities { GrabbedCounts(contracts, events) } - def assertIncreasingRecordTime(domain: DomainAlias, pr: LocalParticipantReferenceCommon): Unit = + def assertIncreasingRecordTime[ParticipantNodeT <: ParticipantNodeCommon]( + domain: DomainAlias, + pr: LocalParticipantReferenceCommon[ParticipantNodeT], + ): Unit = assertIncreasingRecordTime(domain, alias => pr.testing.event_search(alias)) def assertIncreasingRecordTime( @@ -140,6 +145,23 @@ object IntegrationTestUtilities { } } + def extractSubmissionResultV2(tree: TransactionTreeV2): Value.Sum = { + require( + tree.rootEventIds.size == 1, + s"Received transaction with not exactly one root node: $tree", + ) + tree.eventsById(tree.rootEventIds.head).kind match { + case Created(created) => Value.Sum.ContractId(created.contractId) + case Exercised(exercised) => + val Value(result) = exercised.exerciseResult.getOrElse( + throw new RuntimeException("Unable to exercise choice.") + ) + result + case TreeEvent.Kind.Empty => + throw new IllegalArgumentException(s"Received transaction with empty event kind: $tree") + } + } + def poll[T](timeout: FiniteDuration, interval: FiniteDuration)(testCode: => T): T = { require(timeout >= Duration.Zero) require(interval >= Duration.Zero) diff --git a/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/ApiCommandSubmissionServiceV2.scala b/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/ApiCommandSubmissionServiceV2.scala index c47efbd747..89fd879b4d 100644 --- a/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/ApiCommandSubmissionServiceV2.scala +++ b/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/apiserver/services/ApiCommandSubmissionServiceV2.scala @@ -31,6 +31,7 @@ import com.digitalasset.canton.logging.{ } import com.digitalasset.canton.metrics.Metrics import com.digitalasset.canton.tracing.Traced +import com.digitalasset.canton.util.OptionUtil import java.time.{Duration, Instant} import scala.concurrent.{ExecutionContext, Future} @@ -83,7 +84,9 @@ final class ApiCommandSubmissionServiceV2( currentLedgerTime = currentLedgerTime(), currentUtcTime = currentUtcTime(), maxDeduplicationDuration = maxDeduplicationDuration(), - domainIdString = requestWithSubmissionId.commands.map(_.domainId), + domainIdString = requestWithSubmissionId.commands.flatMap(commands => + OptionUtil.emptyStringAsNone(commands.domainId) + ), )(errorLogger), ) .fold( diff --git a/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/parallel/BatchingParallelIngestionPipe.scala b/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/parallel/BatchingParallelIngestionPipe.scala index 8fc5a7cd71..a6d9bc9bd2 100644 --- a/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/parallel/BatchingParallelIngestionPipe.scala +++ b/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/indexer/parallel/BatchingParallelIngestionPipe.scala @@ -3,6 +3,7 @@ package com.digitalasset.canton.platform.indexer.parallel +import com.digitalasset.canton.util.PekkoUtil.syntax.* import org.apache.pekko.NotUsed import org.apache.pekko.stream.scaladsl.Source @@ -26,7 +27,7 @@ object BatchingParallelIngestionPipe { // Stage 1: the stream coming from ReadService, involves deserialization and translation to Update-s source // Stage 2: Batching plus mapping to Database DTOs encapsulates all the CPU intensive computation of the ingestion. Executed in parallel. - .via(BatchN(submissionBatchSize.toInt, inputMappingParallelism)) + .batchN(submissionBatchSize.toInt, inputMappingParallelism) .mapAsync(inputMappingParallelism)(inputMapper) // Stage 3: Encapsulates sequential/stateful computation (generation of sequential IDs for events) .scan(seqMapperZero)(seqMapper) diff --git a/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/ACSReader.scala b/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/ACSReader.scala index 1209a9e083..4433025de1 100644 --- a/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/ACSReader.scala +++ b/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/ACSReader.scala @@ -29,7 +29,6 @@ import com.digitalasset.canton.logging.{ import com.digitalasset.canton.metrics.Metrics import com.digitalasset.canton.platform.TemplatePartiesFilter import com.digitalasset.canton.platform.config.ActiveContractsServiceStreamsConfig -import com.digitalasset.canton.platform.indexer.parallel.BatchN import com.digitalasset.canton.platform.store.backend.EventStorageBackend import com.digitalasset.canton.platform.store.backend.EventStorageBackend.{ RawActiveContract, @@ -55,6 +54,7 @@ import com.digitalasset.canton.platform.store.utils.{ Telemetry, } import com.digitalasset.canton.tracing.TraceContext +import com.digitalasset.canton.util.PekkoUtil.syntax.* import io.opentelemetry.api.trace.Tracer import org.apache.pekko.NotUsed import org.apache.pekko.stream.Attributes @@ -527,11 +527,9 @@ class ACSReader( decomposedFilters .map(fetchCreateIds) .pipe(EventIdsUtils.sortAndDeduplicateIds) - .via( - BatchN( - maxBatchSize = config.maxPayloadsPerPayloadsPage, - maxBatchCount = config.maxParallelPayloadCreateQueries + 1, - ) + .batchN( + maxBatchSize = config.maxPayloadsPerPayloadsPage, + maxBatchCount = config.maxParallelPayloadCreateQueries + 1, ) .async .addAttributes(Attributes.inputBuffer(initial = inputBufferSize, max = inputBufferSize)) @@ -541,11 +539,9 @@ class ACSReader( decomposedFilters .map(fetchAssignIds) .pipe(EventIdsUtils.sortAndDeduplicateIds) - .via( - BatchN( - maxBatchSize = config.maxPayloadsPerPayloadsPage, - maxBatchCount = config.maxParallelPayloadCreateQueries + 1, - ) + .batchN( + maxBatchSize = config.maxPayloadsPerPayloadsPage, + maxBatchCount = config.maxParallelPayloadCreateQueries + 1, ) .async .addAttributes(Attributes.inputBuffer(initial = inputBufferSize, max = inputBufferSize)) @@ -614,11 +610,9 @@ class ACSReader( decomposedFilters .map(fetchCreateIds) .pipe(EventIdsUtils.sortAndDeduplicateIds) - .via( - BatchN( - maxBatchSize = config.maxPayloadsPerPayloadsPage, - maxBatchCount = config.maxParallelPayloadCreateQueries + 1, - ) + .batchN( + maxBatchSize = config.maxPayloadsPerPayloadsPage, + maxBatchCount = config.maxParallelPayloadCreateQueries + 1, ) .async .addAttributes(Attributes.inputBuffer(initial = inputBufferSize, max = inputBufferSize)) diff --git a/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/ContractLoader.scala b/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/ContractLoader.scala index 33a1ac6bf3..b0b7d50137 100644 --- a/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/ContractLoader.scala +++ b/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/ContractLoader.scala @@ -16,7 +16,6 @@ import com.digitalasset.canton.logging.{ NamedLogging, } import com.digitalasset.canton.metrics.Metrics -import com.digitalasset.canton.platform.indexer.parallel.BatchN import com.digitalasset.canton.platform.store.backend.ContractStorageBackend import com.digitalasset.canton.platform.store.backend.ContractStorageBackend.{ RawArchivedContract, @@ -24,6 +23,7 @@ import com.digitalasset.canton.platform.store.backend.ContractStorageBackend.{ RawCreatedContract, } import com.digitalasset.canton.platform.store.dao.DbDispatcher +import com.digitalasset.canton.util.PekkoUtil.syntax.* import io.grpc.{Metadata, StatusRuntimeException} import org.apache.pekko.stream.scaladsl.{Keep, Sink, Source} import org.apache.pekko.stream.{BoundedSourceQueue, Materializer, QueueOfferResult} @@ -53,11 +53,9 @@ class PekkoStreamParallelBatchedLoader[KEY, VALUE]( with NamedLogging { private val (queue, done) = createQueue() - .via( - BatchN( - maxBatchSize = maxBatchSize, - maxBatchCount = parallelism, - ) + .batchN( + maxBatchSize = maxBatchSize, + maxBatchCount = parallelism, ) .mapAsyncUnordered(parallelism) { batch => Future diff --git a/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/ReassignmentStreamReader.scala b/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/ReassignmentStreamReader.scala index 9390858740..bab75e05fb 100644 --- a/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/ReassignmentStreamReader.scala +++ b/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/ReassignmentStreamReader.scala @@ -11,7 +11,6 @@ import com.digitalasset.canton.ledger.offset.Offset import com.digitalasset.canton.logging.LoggingContextWithTrace.implicitExtractTraceContext import com.digitalasset.canton.logging.{LoggingContextWithTrace, NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.metrics.Metrics -import com.digitalasset.canton.platform.indexer.parallel.BatchN import com.digitalasset.canton.platform.store.backend.EventStorageBackend import com.digitalasset.canton.platform.store.backend.EventStorageBackend.{ RawAssignEvent, @@ -34,6 +33,7 @@ import com.digitalasset.canton.platform.store.utils.{ } import com.digitalasset.canton.platform.{ApiOffset, TemplatePartiesFilter} import com.digitalasset.canton.tracing.TraceContext +import com.digitalasset.canton.util.PekkoUtil.syntax.* import io.opentelemetry.api.trace.Tracer import org.apache.pekko.NotUsed import org.apache.pekko.stream.Attributes @@ -104,11 +104,9 @@ class ReassignmentStreamReader( ) } .pipe(EventIdsUtils.sortAndDeduplicateIds) - .via( - BatchN( - maxBatchSize = maxPayloadsPerPayloadsPage, - maxBatchCount = maxOutputBatchCount, - ) + .batchN( + maxBatchSize = maxPayloadsPerPayloadsPage, + maxBatchCount = maxOutputBatchCount, ) } diff --git a/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/TransactionsFlatStreamReader.scala b/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/TransactionsFlatStreamReader.scala index 5a7f8b9d2e..209c68f329 100644 --- a/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/TransactionsFlatStreamReader.scala +++ b/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/TransactionsFlatStreamReader.scala @@ -15,7 +15,6 @@ import com.digitalasset.canton.logging.LoggingContextWithTrace.implicitExtractTr import com.digitalasset.canton.logging.{LoggingContextWithTrace, NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.metrics.Metrics import com.digitalasset.canton.platform.config.TransactionFlatStreamsConfig -import com.digitalasset.canton.platform.indexer.parallel.BatchN import com.digitalasset.canton.platform.store.backend.EventStorageBackend import com.digitalasset.canton.platform.store.backend.common.{ EventIdSourceForStakeholders, @@ -36,6 +35,7 @@ import com.digitalasset.canton.platform.store.utils.{ } import com.digitalasset.canton.platform.{ApiOffset, TemplatePartiesFilter} import com.digitalasset.canton.tracing.TraceContext +import com.digitalasset.canton.util.PekkoUtil.syntax.* import io.opentelemetry.api.trace.Tracer import org.apache.pekko.NotUsed import org.apache.pekko.stream.Attributes @@ -172,11 +172,9 @@ class TransactionsFlatStreamReader( ) } .pipe(EventIdsUtils.sortAndDeduplicateIds) - .via( - BatchN( - maxBatchSize = maxPayloadsPerPayloadsPage, - maxBatchCount = maxOutputBatchCount, - ) + .batchN( + maxBatchSize = maxPayloadsPerPayloadsPage, + maxBatchCount = maxOutputBatchCount, ) } diff --git a/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/TransactionsTreeStreamReader.scala b/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/TransactionsTreeStreamReader.scala index 894e08bb9f..6c0b29bbd8 100644 --- a/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/TransactionsTreeStreamReader.scala +++ b/canton-3x/community/ledger/ledger-api-core/src/main/scala/com/digitalasset/canton/platform/store/dao/events/TransactionsTreeStreamReader.scala @@ -15,7 +15,6 @@ import com.digitalasset.canton.logging.LoggingContextWithTrace.implicitExtractTr import com.digitalasset.canton.logging.{LoggingContextWithTrace, NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.metrics.Metrics import com.digitalasset.canton.platform.config.TransactionTreeStreamsConfig -import com.digitalasset.canton.platform.indexer.parallel.BatchN import com.digitalasset.canton.platform.store.backend.EventStorageBackend import com.digitalasset.canton.platform.store.backend.common.{ EventIdSourceForInformees, @@ -35,6 +34,7 @@ import com.digitalasset.canton.platform.store.utils.{ Telemetry, } import com.digitalasset.canton.platform.{ApiOffset, Party, TemplatePartiesFilter} +import com.digitalasset.canton.util.PekkoUtil.syntax.* import io.opentelemetry.api.trace.Tracer import org.apache.pekko.NotUsed import org.apache.pekko.stream.Attributes @@ -337,11 +337,9 @@ class TransactionsTreeStreamReader( )(sourcesOfIds: Vector[Source[Long, NotUsed]]): Source[Iterable[Long], NotUsed] = { EventIdsUtils .sortAndDeduplicateIds(sourcesOfIds) - .via( - BatchN( - maxBatchSize = maxOutputBatchSize, - maxBatchCount = maxOutputBatchCount, - ) + .batchN( + maxBatchSize = maxOutputBatchSize, + maxBatchCount = maxOutputBatchCount, ) } diff --git a/canton-3x/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/execution/StoreBackedCommandExecutorSpec.scala b/canton-3x/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/execution/StoreBackedCommandExecutorSpec.scala index c341cd171a..e26978682d 100644 --- a/canton-3x/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/execution/StoreBackedCommandExecutorSpec.scala +++ b/canton-3x/community/ledger/ledger-api-core/src/test/scala/com/digitalasset/canton/platform/apiserver/execution/StoreBackedCommandExecutorSpec.scala @@ -93,6 +93,8 @@ class StoreBackedCommandExecutorSpec participantId = any[ParticipantId], submissionSeed = any[Hash], disclosures = any[ImmArray[LfDisclosedContract]], + packageMap = any[Map[Ref.PackageId, (Ref.PackageName, Ref.PackageVersion)]], + packagePreference = any[Set[Ref.PackageId]], )(any[LoggingContext]) ) .thenReturn(result) @@ -295,6 +297,8 @@ class StoreBackedCommandExecutorSpec participantId = any[ParticipantId], submissionSeed = any[Hash], disclosures = any[ImmArray[LfDisclosedContract]], + packageMap = any[Map[Ref.PackageId, (Ref.PackageName, Ref.PackageVersion)]], + packagePreference = any[Set[Ref.PackageId]], )(any[LoggingContext]) ).thenReturn(engineResult) diff --git a/canton-3x/community/ledger/ledger-common-dars/src/main/daml/benchtool/daml.yaml b/canton-3x/community/ledger/ledger-common-dars/src/main/daml/benchtool/daml.yaml index 118e073ced..ea57e89d4e 100644 --- a/canton-3x/community/ledger/ledger-common-dars/src/main/daml/benchtool/daml.yaml +++ b/canton-3x/community/ledger/ledger-common-dars/src/main/daml/benchtool/daml.yaml @@ -1,8 +1,8 @@ -sdk-version: 2.9.0-snapshot.20231215.12512.0.v5a0f0a18 +sdk-version: 2.9.0-snapshot.20231231.12528.0.vca9bd5e6 name: benchtool-tests source: . version: 3.0.0 -dependencies: +dependencies: - daml-prim - daml-stdlib - daml3-script diff --git a/canton-3x/community/ledger/ledger-common-dars/src/main/daml/carbonv1/daml.yaml b/canton-3x/community/ledger/ledger-common-dars/src/main/daml/carbonv1/daml.yaml index aa5c959f27..2a73944f0d 100644 --- a/canton-3x/community/ledger/ledger-common-dars/src/main/daml/carbonv1/daml.yaml +++ b/canton-3x/community/ledger/ledger-common-dars/src/main/daml/carbonv1/daml.yaml @@ -1,8 +1,8 @@ -sdk-version: 2.9.0-snapshot.20231215.12512.0.v5a0f0a18 +sdk-version: 2.9.0-snapshot.20231231.12528.0.vca9bd5e6 name: carbonv1-tests source: . version: 3.0.0 -dependencies: +dependencies: - daml-prim - daml-stdlib - daml3-script diff --git a/canton-3x/community/ledger/ledger-common-dars/src/main/daml/carbonv2/daml.yaml b/canton-3x/community/ledger/ledger-common-dars/src/main/daml/carbonv2/daml.yaml index 852d64a0bd..c785accc5a 100644 --- a/canton-3x/community/ledger/ledger-common-dars/src/main/daml/carbonv2/daml.yaml +++ b/canton-3x/community/ledger/ledger-common-dars/src/main/daml/carbonv2/daml.yaml @@ -1,10 +1,10 @@ -sdk-version: 2.9.0-snapshot.20231215.12512.0.v5a0f0a18 +sdk-version: 2.9.0-snapshot.20231231.12528.0.vca9bd5e6 name: carbonv2-tests -data-dependencies: - - ../../../../scala-2.13/resource_managed/main/carbonv1-tests.dar +data-dependencies: +- ../../../../scala-2.13/resource_managed/main/carbonv1-tests.dar source: . version: 3.0.0 -dependencies: +dependencies: - daml-prim - daml-stdlib - daml3-script diff --git a/canton-3x/community/ledger/ledger-common-dars/src/main/daml/carbonv3/daml.yaml b/canton-3x/community/ledger/ledger-common-dars/src/main/daml/carbonv3/daml.yaml index 67d30f6a43..eaec544307 100644 --- a/canton-3x/community/ledger/ledger-common-dars/src/main/daml/carbonv3/daml.yaml +++ b/canton-3x/community/ledger/ledger-common-dars/src/main/daml/carbonv3/daml.yaml @@ -1,10 +1,10 @@ -sdk-version: 2.9.0-snapshot.20231215.12512.0.v5a0f0a18 +sdk-version: 2.9.0-snapshot.20231231.12528.0.vca9bd5e6 name: carbonv3-tests -data-dependencies: +data-dependencies: - ../../../../scala-2.13/resource_managed/main/carbonv2-tests.dar source: . version: 3.0.0 -dependencies: +dependencies: - daml-prim - daml-stdlib - daml3-script diff --git a/canton-3x/community/ledger/ledger-common-dars/src/main/daml/model/daml.yaml b/canton-3x/community/ledger/ledger-common-dars/src/main/daml/model/daml.yaml index 6f8b8ca9a1..5dbee99e4d 100644 --- a/canton-3x/community/ledger/ledger-common-dars/src/main/daml/model/daml.yaml +++ b/canton-3x/community/ledger/ledger-common-dars/src/main/daml/model/daml.yaml @@ -1,8 +1,8 @@ -sdk-version: 2.9.0-snapshot.20231215.12512.0.v5a0f0a18 +sdk-version: 2.9.0-snapshot.20231231.12528.0.vca9bd5e6 name: model-tests source: . version: 3.0.0 -dependencies: +dependencies: - daml-prim - daml-stdlib - daml3-script diff --git a/canton-3x/community/ledger/ledger-common-dars/src/main/daml/package_management/daml.yaml b/canton-3x/community/ledger/ledger-common-dars/src/main/daml/package_management/daml.yaml index f51b448710..9709bbff68 100644 --- a/canton-3x/community/ledger/ledger-common-dars/src/main/daml/package_management/daml.yaml +++ b/canton-3x/community/ledger/ledger-common-dars/src/main/daml/package_management/daml.yaml @@ -1,8 +1,8 @@ -sdk-version: 2.9.0-snapshot.20231215.12512.0.v5a0f0a18 +sdk-version: 2.9.0-snapshot.20231231.12528.0.vca9bd5e6 name: package-management-tests source: . version: 3.0.0 -dependencies: +dependencies: - daml-prim - daml-stdlib - daml3-script diff --git a/canton-3x/community/ledger/ledger-common-dars/src/main/daml/semantic/daml.yaml b/canton-3x/community/ledger/ledger-common-dars/src/main/daml/semantic/daml.yaml index dfa6e018c8..9e525fea8f 100644 --- a/canton-3x/community/ledger/ledger-common-dars/src/main/daml/semantic/daml.yaml +++ b/canton-3x/community/ledger/ledger-common-dars/src/main/daml/semantic/daml.yaml @@ -1,8 +1,8 @@ -sdk-version: 2.9.0-snapshot.20231215.12512.0.v5a0f0a18 +sdk-version: 2.9.0-snapshot.20231231.12528.0.vca9bd5e6 name: semantic-tests source: . version: 3.0.0 -dependencies: +dependencies: - daml-prim - daml-stdlib - daml3-script diff --git a/canton-3x/community/ledger/ledger-json-api/src/test/daml/daml.yaml b/canton-3x/community/ledger/ledger-json-api/src/test/daml/daml.yaml index adc4c92445..b7338eded6 100644 --- a/canton-3x/community/ledger/ledger-json-api/src/test/daml/daml.yaml +++ b/canton-3x/community/ledger/ledger-json-api/src/test/daml/daml.yaml @@ -1,10 +1,10 @@ -sdk-version: 2.9.0-snapshot.20231215.12512.0.v5a0f0a18 -build-options: +sdk-version: 2.9.0-snapshot.20231231.12528.0.vca9bd5e6 +build-options: - --target=2.1 name: JsonEncodingTest source: JsonEncodingTest.daml version: 3.0.0 -dependencies: +dependencies: - daml-prim - daml-stdlib - daml3-script diff --git a/canton-3x/community/participant/src/main/daml/daml.yaml b/canton-3x/community/participant/src/main/daml/daml.yaml index ee1be2e953..b6e55a510d 100644 --- a/canton-3x/community/participant/src/main/daml/daml.yaml +++ b/canton-3x/community/participant/src/main/daml/daml.yaml @@ -1,10 +1,10 @@ -sdk-version: 2.9.0-snapshot.20231215.12512.0.v5a0f0a18 -build-options: - - --target=2.1 +sdk-version: 2.9.0-snapshot.20231231.12528.0.vca9bd5e6 +build-options: +- --target=2.1 name: AdminWorkflows source: AdminWorkflows.daml version: 3.0.0 -dependencies: +dependencies: - daml-prim - daml-stdlib - daml3-script diff --git a/canton-3x/community/participant/src/main/daml/ping-pong-vacuum/daml.yaml b/canton-3x/community/participant/src/main/daml/ping-pong-vacuum/daml.yaml index 8cc4035614..58572902d6 100644 --- a/canton-3x/community/participant/src/main/daml/ping-pong-vacuum/daml.yaml +++ b/canton-3x/community/participant/src/main/daml/ping-pong-vacuum/daml.yaml @@ -1,12 +1,12 @@ -sdk-version: 2.9.0-snapshot.20231215.12512.0.v5a0f0a18 -build-options: +sdk-version: 2.9.0-snapshot.20231231.12528.0.vca9bd5e6 +build-options: - --target=2.1 name: AdminWorkflowsWithVacuuming -data-dependencies: -- ../../../src/main/resources/dar/AdminWorkflows.dar +data-dependencies: +- ../../../target/scala-2.13/resource_managed/main/AdminWorkflows.dar source: PingPongVacuum.daml version: 3.0.0 -dependencies: +dependencies: - daml-prim - daml-stdlib - daml3-script diff --git a/canton-3x/community/participant/src/main/resources/dar/AdminWorkflows.dar b/canton-3x/community/participant/src/main/resources/dar/AdminWorkflows.dar deleted file mode 100644 index 9465f51a785b6b6e6624063423430684b2c8173c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 375310 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≧vBmgqIeOLqW+=pG3rNrA`H6P}#_20zYJ` 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�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

QrBw3??A%l6+CLW2 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

orR*S5OywrUtVK~^n4dWwTu;N#whY79H0kK^Zu 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 Seq[ComponentStatus], ) extends ParticipantNodeCommon(sync) with NoTracing { diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/ParticipantNodeCommon.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/ParticipantNodeCommon.scala index fda3ebb001..0e8911498b 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/ParticipantNodeCommon.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/ParticipantNodeCommon.scala @@ -10,6 +10,7 @@ import cats.syntax.option.* import com.daml.grpc.adapter.ExecutionSequencerFactory import com.daml.lf.engine.Engine import com.digitalasset.canton.LedgerParticipantId +import com.digitalasset.canton.admin.participant.v0.* import com.digitalasset.canton.common.domain.grpc.SequencerInfoLoader import com.digitalasset.canton.concurrent.{ ExecutionContextIdlenessExecutorService, @@ -17,7 +18,7 @@ import com.digitalasset.canton.concurrent.{ } import com.digitalasset.canton.config.CantonRequireTypes.InstanceName import com.digitalasset.canton.config.{DbConfig, H2DbConfig} -import com.digitalasset.canton.crypto.{Crypto, SyncCryptoApiProvider} +import com.digitalasset.canton.crypto.{Crypto, CryptoPureApi, SyncCryptoApiProvider} import com.digitalasset.canton.domain.api.v0.DomainTimeServiceGrpc import com.digitalasset.canton.environment.{CantonNode, CantonNodeBootstrapCommon} import com.digitalasset.canton.health.MutableHealthComponent @@ -26,7 +27,6 @@ import com.digitalasset.canton.lifecycle.FutureUnlessShutdown import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.metrics.Metrics as LedgerApiServerMetrics import com.digitalasset.canton.participant.admin.grpc.* -import com.digitalasset.canton.participant.admin.v0.* import com.digitalasset.canton.participant.admin.{ DomainConnectivityService, PackageDependencyResolver, @@ -599,6 +599,11 @@ abstract class ParticipantNodeCommon( ) extends CantonNode with NamedLogging with HasUptime { + + def cryptoPureApi: CryptoPureApi + + override def loggerFactory: NamedLoggerFactory + def reconnectDomainsIgnoreFailures()(implicit traceContext: TraceContext, ec: ExecutionContext, diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/ParticipantNodeX.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/ParticipantNodeX.scala index a405f65c5c..cf8fc3d831 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/ParticipantNodeX.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/ParticipantNodeX.scala @@ -402,7 +402,7 @@ class ParticipantNodeX( val nodeParameters: ParticipantNodeParameters, storage: Storage, override protected val clock: Clock, - val cryptoPureApi: CryptoPureApi, + override val cryptoPureApi: CryptoPureApi, identityPusher: ParticipantTopologyDispatcherCommon, private[canton] val ips: IdentityProvidingServiceClient, override private[canton] val sync: CantonSyncService, diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/AdminWorkflowServices.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/AdminWorkflowServices.scala index 175e01fff4..dfa9c29290 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/AdminWorkflowServices.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/AdminWorkflowServices.scala @@ -34,7 +34,10 @@ import com.digitalasset.canton.participant.sync.SyncServiceInjectionError.Passiv import com.digitalasset.canton.participant.topology.ParticipantTopologyManagerError import com.digitalasset.canton.time.Clock import com.digitalasset.canton.topology.PartyId -import com.digitalasset.canton.topology.TopologyManagerError.NoAppropriateSigningKeyInStore +import com.digitalasset.canton.topology.TopologyManagerError.{ + NoAppropriateSigningKeyInStore, + SecretKeyNotInStore, +} import com.digitalasset.canton.tracing.TraceContext.withNewTraceContext import com.digitalasset.canton.tracing.{NoTracing, Spanning, TraceContext, TracerProvider} import com.digitalasset.canton.util.FutureInstances.* @@ -135,7 +138,7 @@ class AdminWorkflowServices( .leftSubflatMap(res) { case CantonPackageServiceError.IdentityManagerParentError( ParticipantTopologyManagerError.IdentityManagerParentError( - NoAppropriateSigningKeyInStore.Failure(_) + NoAppropriateSigningKeyInStore.Failure(_) | SecretKeyNotInStore.Failure(_) ) ) => // Log error by creating error object, but continue processing. diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/DomainConnectivityService.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/DomainConnectivityService.scala index 0aeac1d553..9a30d8f62b 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/DomainConnectivityService.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/DomainConnectivityService.scala @@ -6,6 +6,7 @@ package com.digitalasset.canton.participant.admin import cats.data.EitherT import cats.syntax.parallel.* import com.digitalasset.canton.DomainAlias +import com.digitalasset.canton.admin.participant.v0 import com.digitalasset.canton.common.domain.ServiceAgreementId import com.digitalasset.canton.common.domain.grpc.SequencerInfoLoader import com.digitalasset.canton.common.domain.grpc.SequencerInfoLoader.SequencerAggregatedInfo diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/ResourceLimits.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/ResourceLimits.scala index a88b148103..8368674e85 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/ResourceLimits.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/ResourceLimits.scala @@ -3,6 +3,7 @@ package com.digitalasset.canton.participant.admin +import com.digitalasset.canton.admin.participant.v0 import com.digitalasset.canton.config.RequireTypes.{NonNegativeInt, PositiveDouble, PositiveNumeric} /** Encapsulated resource limits for a participant. diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/data/ActiveContract.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/data/ActiveContract.scala index 2ac05cb5a7..17a51cc639 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/data/ActiveContract.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/data/ActiveContract.scala @@ -5,7 +5,7 @@ package com.digitalasset.canton.participant.admin.data import better.files.File import cats.syntax.either.* -import com.digitalasset.canton.participant.admin.v1 +import com.digitalasset.canton.admin.participant.v1 import com.digitalasset.canton.protocol.messages.HasDomainId import com.digitalasset.canton.protocol.{HasSerializableContract, SerializableContract} import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcDomainConnectivityService.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcDomainConnectivityService.scala index 813630dbe7..c462dac62b 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcDomainConnectivityService.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcDomainConnectivityService.scala @@ -3,8 +3,8 @@ package com.digitalasset.canton.participant.admin.grpc +import com.digitalasset.canton.admin.participant.v0.* import com.digitalasset.canton.participant.admin.DomainConnectivityService -import com.digitalasset.canton.participant.admin.v0.* import com.digitalasset.canton.tracing.{TraceContext, TraceContextGrpc} import io.grpc.Status diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcInspectionService.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcInspectionService.scala index 01a4b2c2e0..430b2e11ce 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcInspectionService.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcInspectionService.scala @@ -6,15 +6,15 @@ package com.digitalasset.canton.participant.admin.grpc import cats.syntax.either.* import cats.syntax.parallel.* import com.digitalasset.canton.LedgerTransactionId -import com.digitalasset.canton.data.CantonTimestamp -import com.digitalasset.canton.participant.admin.inspection.SyncStateInspection -import com.digitalasset.canton.participant.admin.v0.InspectionServiceGrpc.InspectionService -import com.digitalasset.canton.participant.admin.v0.{ +import com.digitalasset.canton.admin.participant.v0.InspectionServiceGrpc.InspectionService +import com.digitalasset.canton.admin.participant.v0.{ LookupContractDomain, LookupOffsetByIndex, LookupOffsetByTime, LookupTransactionDomain, } +import com.digitalasset.canton.data.CantonTimestamp +import com.digitalasset.canton.participant.admin.inspection.SyncStateInspection import com.digitalasset.canton.protocol.LfContractId import com.digitalasset.canton.tracing.{TraceContext, TraceContextGrpc} import com.digitalasset.canton.util.FutureInstances.* diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcPackageService.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcPackageService.scala index df4266d230..37df8dd32e 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcPackageService.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcPackageService.scala @@ -5,12 +5,13 @@ package com.digitalasset.canton.participant.admin.grpc import cats.data.EitherT import cats.syntax.either.* +import com.digitalasset.canton.admin.participant.v0 +import com.digitalasset.canton.admin.participant.v0.{DarDescription as ProtoDarDescription, *} import com.digitalasset.canton.crypto.Hash import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.networking.grpc.CantonGrpcUtil.GrpcErrors +import com.digitalasset.canton.participant.admin.PackageService import com.digitalasset.canton.participant.admin.PackageService.DarDescriptor -import com.digitalasset.canton.participant.admin.* -import com.digitalasset.canton.participant.admin.v0.{DarDescription as ProtoDarDescription, *} import com.digitalasset.canton.tracing.{TraceContext, TraceContextGrpc} import com.digitalasset.canton.util.{EitherTUtil, OptionUtil} import com.digitalasset.canton.{LfPackageId, protocol} diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcParticipantRepairService.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcParticipantRepairService.scala index 3a33ca334e..cd2b917005 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcParticipantRepairService.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcParticipantRepairService.scala @@ -7,6 +7,7 @@ import better.files.* import cats.data.EitherT import cats.syntax.all.* import com.daml.nonempty.NonEmpty +import com.digitalasset.canton.admin.participant.v0.* import com.digitalasset.canton.config.ProcessingTimeout import com.digitalasset.canton.config.RequireTypes.PositiveInt import com.digitalasset.canton.data.{CantonTimestamp, RepairContract} @@ -21,7 +22,6 @@ import com.digitalasset.canton.participant.admin.grpc.GrpcParticipantRepairServi import com.digitalasset.canton.participant.admin.inspection import com.digitalasset.canton.participant.admin.repair.RepairServiceError import com.digitalasset.canton.participant.admin.repair.RepairServiceError.ImportAcsError -import com.digitalasset.canton.participant.admin.v0.* import com.digitalasset.canton.participant.domain.DomainConnectionConfig import com.digitalasset.canton.participant.sync.CantonSyncService import com.digitalasset.canton.protocol.{LfContractId, SerializableContract} diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcPartyNameManagementService.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcPartyNameManagementService.scala index 7a6d8e8d56..66f41b80c9 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcPartyNameManagementService.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcPartyNameManagementService.scala @@ -5,12 +5,12 @@ package com.digitalasset.canton.participant.admin.grpc import cats.data.EitherT import cats.syntax.either.* -import com.digitalasset.canton.config.CantonRequireTypes.String255 -import com.digitalasset.canton.participant.admin.v0.{ +import com.digitalasset.canton.admin.participant.v0.{ PartyNameManagementServiceGrpc, SetPartyDisplayNameRequest, SetPartyDisplayNameResponse, } +import com.digitalasset.canton.config.CantonRequireTypes.String255 import com.digitalasset.canton.participant.topology.LedgerServerPartyNotifier import com.digitalasset.canton.topology.{PartyId, UniqueIdentifier} import com.digitalasset.canton.tracing.{TraceContext, TraceContextGrpc} diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcPingService.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcPingService.scala index 386641482c..26ec27c2f3 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcPingService.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcPingService.scala @@ -3,9 +3,9 @@ package com.digitalasset.canton.participant.admin.grpc +import com.digitalasset.canton.admin.participant.v0.* import com.digitalasset.canton.ledger.api.refinements.ApiTypes.WorkflowId import com.digitalasset.canton.participant.admin.PingService -import com.digitalasset.canton.participant.admin.v0.* import com.digitalasset.canton.tracing.Spanning import io.opentelemetry.api.trace.Tracer diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcPruningService.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcPruningService.scala index c0c7da4cf9..c5adc0721b 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcPruningService.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcPruningService.scala @@ -8,20 +8,20 @@ import cats.syntax.either.* import com.daml.error.{ErrorCategory, ErrorCode, Explanation, Resolution} import com.digitalasset.canton.ProtoDeserializationError import com.digitalasset.canton.admin.grpc.{GrpcPruningScheduler, HasPruningScheduler} +import com.digitalasset.canton.admin.participant.v0.* +import com.digitalasset.canton.admin.pruning.v0 import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.error.CantonError import com.digitalasset.canton.error.CantonErrorGroups.ParticipantErrorGroup.PruningServiceErrorGroup import com.digitalasset.canton.ledger.error.groups.RequestValidationErrors.NonHexOffset import com.digitalasset.canton.logging.{ErrorLoggingContext, NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.networking.grpc.CantonGrpcUtil -import com.digitalasset.canton.participant.admin.v0.* import com.digitalasset.canton.participant.scheduler.{ ParticipantPruningSchedule, ParticipantPruningScheduler, } import com.digitalasset.canton.participant.sync.{CantonSyncService, UpstreamOffsetConvert} import com.digitalasset.canton.participant.{GlobalOffset, Pruning} -import com.digitalasset.canton.pruning.admin.v0 import com.digitalasset.canton.serialization.ProtoConverter import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult import com.digitalasset.canton.tracing.{TraceContext, TraceContextGrpc} @@ -87,7 +87,7 @@ class GrpcPruningService( (beforeOrAt, ledgerEndOffset) = validatedRequest - safeOffsetO <- sync.stateInspection + safeOffsetO <- sync.pruningProcessor .safeToPrune(beforeOrAt, ledgerEndOffset) .leftFlatMap[Option[GlobalOffset], StatusRuntimeException] { case Pruning.LedgerPruningOnlySupportedInEnterpriseEdition => diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcResourceManagementService.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcResourceManagementService.scala index 3b741676e1..b8e00977e6 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcResourceManagementService.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcResourceManagementService.scala @@ -3,9 +3,10 @@ package com.digitalasset.canton.participant.admin.grpc +import com.digitalasset.canton.admin.participant.v0 import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.networking.grpc.CantonGrpcUtil.* -import com.digitalasset.canton.participant.admin.{ResourceLimits, ResourceManagementService, v0} +import com.digitalasset.canton.participant.admin.{ResourceLimits, ResourceManagementService} import com.digitalasset.canton.tracing.TraceContext import com.google.protobuf.empty.Empty diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcTrafficControlService.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcTrafficControlService.scala index 084e02de2b..b17d3ae270 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcTrafficControlService.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcTrafficControlService.scala @@ -6,10 +6,10 @@ package com.digitalasset.canton.participant.admin.grpc import cats.data.EitherT import cats.implicits.* import com.digitalasset.canton.ProtoDeserializationError.ProtoDeserializationFailure +import com.digitalasset.canton.admin.participant.v0.* import com.digitalasset.canton.error.CantonError import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.networking.grpc.CantonGrpcUtil -import com.digitalasset.canton.participant.admin.v0.* import com.digitalasset.canton.participant.sync.CantonSyncService import com.digitalasset.canton.participant.traffic.TrafficStateController.TrafficControlError import com.digitalasset.canton.participant.traffic.TrafficStateController.TrafficControlError.TrafficStateNotFound diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcTransferService.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcTransferService.scala index 1d118e0b56..acd28444ec 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcTransferService.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/grpc/GrpcTransferService.scala @@ -7,9 +7,9 @@ import cats.data.EitherT import cats.syntax.traverse.* import com.digitalasset.canton.DomainAlias import com.digitalasset.canton.ProtoDeserializationError.FieldNotSet +import com.digitalasset.canton.admin.participant.v0.* import com.digitalasset.canton.data.{CantonTimestamp, TransferSubmitterMetadata} import com.digitalasset.canton.participant.admin.TransferService -import com.digitalasset.canton.participant.admin.v0.* import com.digitalasset.canton.participant.protocol.transfer.TransferData import com.digitalasset.canton.protocol.ContractIdSyntax.* import com.digitalasset.canton.protocol.{LfContractId, TransferId} @@ -68,7 +68,7 @@ class GrpcTransferService(service: TransferService, participantId: ParticipantId targetDomain, ) ) - } yield AdminTransferOutResponse(transferId = Some(transferId.toProtoV0)) + } yield AdminTransferOutResponse(transferId = Some(transferId.toAdminProto)) EitherTUtil.toFuture(res) } @@ -87,7 +87,7 @@ class GrpcTransferService(service: TransferService, participantId: ParticipantId targetDomain <- mapErr(DomainAlias.create(targetDomainP)) submittingParty <- mapErr(ProtoConverter.parseLfPartyId(submittingPartyIdP)) transferId <- transferIdP - .map(id => mapErr(TransferId.fromProtoV0(id))) + .map(id => mapErr(TransferId.fromAdminProtoV0(id))) .getOrElse(mapErr(Left(invalidArgument("TransferId not set in transfer-in request")))) applicationId <- mapErr(ProtoConverter.parseLFApplicationId(applicationIdP)) @@ -168,7 +168,7 @@ final case class TransferSearchResult( def toProtoV0: AdminTransferSearchResponse.TransferSearchResult = AdminTransferSearchResponse.TransferSearchResult( contractId = contractId.toProtoPrimitive, - transferId = Some(transferId.toProtoV0), + transferId = Some(transferId.toAdminProto), originDomain = sourceDomain, targetDomain = targetDomain, submittingParty = submittingParty, @@ -197,7 +197,7 @@ object TransferSearchResult { contractId <- ProtoConverter.parseLfContractId(contractIdP) transferId <- ProtoConverter .required("transferId", transferIdP) - .flatMap(TransferId.fromProtoV0) + .flatMap(TransferId.fromAdminProtoV0) targetTimeProofO <- targetTimeProofOP.traverse(CantonTimestamp.fromProtoPrimitive) _ <- Either.cond(sourceDomain.nonEmpty, (), FieldNotSet("originDomain")) _ <- Either.cond(targetDomain.nonEmpty, (), FieldNotSet("targetDomain")) diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/inspection/SyncStateInspection.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/inspection/SyncStateInspection.scala index 30638245df..5419df2b3b 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/inspection/SyncStateInspection.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/admin/inspection/SyncStateInspection.scala @@ -20,7 +20,6 @@ import com.digitalasset.canton.participant.admin.inspection.Error.{ SerializationIssue, } import com.digitalasset.canton.participant.protocol.RequestJournal -import com.digitalasset.canton.participant.pruning.PruningProcessor import com.digitalasset.canton.participant.store.ActiveContractStore.AcsError import com.digitalasset.canton.participant.store.* import com.digitalasset.canton.participant.sync.{ @@ -29,7 +28,6 @@ import com.digitalasset.canton.participant.sync.{ TimestampedEvent, UpstreamOffsetConvert, } -import com.digitalasset.canton.participant.{GlobalOffset, Pruning} import com.digitalasset.canton.protocol.messages.{ AcsCommitment, CommitmentPeriod, @@ -54,6 +52,7 @@ import com.digitalasset.canton.store.{ import com.digitalasset.canton.topology.{DomainId, ParticipantId, PartyId} import com.digitalasset.canton.tracing.TraceContext import com.digitalasset.canton.util.FutureInstances.* +import com.digitalasset.canton.util.Thereafter.syntax.* import com.digitalasset.canton.util.{EitherTUtil, MonadUtil} import com.digitalasset.canton.version.ProtocolVersion import com.digitalasset.canton.{ @@ -68,12 +67,26 @@ import java.io.OutputStream import java.time.Instant import scala.concurrent.{ExecutionContext, Future} +trait JournalGarbageCollectorControl { + def disable(domainId: DomainId)(implicit traceContext: TraceContext): Future[Unit] + def enable(domainId: DomainId)(implicit traceContext: TraceContext): Unit + +} + +object JournalGarbageCollectorControl { + object NoOp extends JournalGarbageCollectorControl { + override def disable(domainId: DomainId)(implicit traceContext: TraceContext): Future[Unit] = + Future.unit + override def enable(domainId: DomainId)(implicit traceContext: TraceContext): Unit = () + } +} + /** Implements inspection functions for the sync state of a participant node */ final class SyncStateInspection( syncDomainPersistentStateManager: SyncDomainPersistentStateManager, participantNodePersistentState: Eval[ParticipantNodePersistentState], - pruningProcessor: PruningProcessor, timeouts: ProcessingTimeout, + journalCleaningControl: JournalGarbageCollectorControl, override protected val loggerFactory: NamedLoggerFactory, )(implicit ec: ExecutionContext) extends NamedLogging { @@ -145,6 +158,21 @@ final class SyncStateInspection( domain, ) + private def disableJournalCleaningForFilter( + domains: Map[DomainId, SyncDomainPersistentState], + filterDomain: DomainId => Boolean, + )(implicit + traceContext: TraceContext + ): EitherT[Future, Error, Unit] = { + val disabledCleaningF = Future + .sequence(domains.collect { + case (domainId, _) if filterDomain(domainId) => + journalCleaningControl.disable(domainId) + }) + .map(_ => ()) + EitherT.right(disabledCleaningF) + } + // TODO(i14441): Remove deprecated ACS download / upload functionality @deprecated("Use exportAcsDumpActiveContracts", since = "2.8.0") def dumpActiveContracts( @@ -156,24 +184,34 @@ final class SyncStateInspection( contractDomainRenames: Map[DomainId, DomainId], )(implicit traceContext: TraceContext - ): EitherT[Future, Error, Unit] = - MonadUtil.sequentialTraverse_(syncDomainPersistentStateManager.getAll) { - case (domainId, state) if filterDomain(domainId) => - val domainIdForExport = contractDomainRenames.getOrElse(domainId, domainId) - val useProtocolVersion = protocolVersion.getOrElse(state.protocolVersion) - for { - _ <- AcsInspection - .forEachVisibleActiveContract(domainId, state, parties, timestamp) { - case (contract, _) => - val domainToContract = SerializableContractWithDomainId(domainIdForExport, contract) - val encodedContract = domainToContract.encode(useProtocolVersion) - outputStream.write(encodedContract.getBytes) - Right(outputStream.flush()) - } - } yield () - case _ => - EitherTUtil.unit + ): EitherT[Future, Error, Unit] = { + val allDomains = syncDomainPersistentStateManager.getAll + // disable journal cleaning for the duration of the dump + disableJournalCleaningForFilter(allDomains, filterDomain).flatMap { _ => + MonadUtil.sequentialTraverse_(allDomains) { + case (domainId, state) if filterDomain(domainId) => + val domainIdForExport = contractDomainRenames.getOrElse(domainId, domainId) + val useProtocolVersion = protocolVersion.getOrElse(state.protocolVersion) + val ret = for { + _ <- AcsInspection + .forEachVisibleActiveContract(domainId, state, parties, timestamp) { + case (contract, _) => + val domainToContract = + SerializableContractWithDomainId(domainIdForExport, contract) + val encodedContract = domainToContract.encode(useProtocolVersion) + outputStream.write(encodedContract.getBytes) + Right(outputStream.flush()) + } + } yield () + // re-enable journal cleaning after the dump + ret.thereafter { _ => + journalCleaningControl.enable(domainId) + } + case _ => + EitherTUtil.unit + } } + } def allProtocolVersions: Map[DomainId, ProtocolVersion] = syncDomainPersistentStateManager.getAll.view.mapValues(_.protocolVersion).toMap @@ -186,38 +224,48 @@ final class SyncStateInspection( contractDomainRenames: Map[DomainId, (DomainId, ProtocolVersion)], )(implicit traceContext: TraceContext - ): EitherT[Future, Error, Unit] = - MonadUtil.sequentialTraverse_(syncDomainPersistentStateManager.getAll) { - case (domainId, state) if filterDomain(domainId) => - val (domainIdForExport, protocolVersion) = - contractDomainRenames.getOrElse(domainId, (domainId, state.protocolVersion)) + ): EitherT[Future, Error, Unit] = { + val allDomains = syncDomainPersistentStateManager.getAll + // disable journal cleaning for the duration of the dump + disableJournalCleaningForFilter(allDomains, filterDomain).flatMap { _ => + MonadUtil.sequentialTraverse_(allDomains) { + case (domainId, state) if filterDomain(domainId) => + val (domainIdForExport, protocolVersion) = + contractDomainRenames.getOrElse(domainId, (domainId, state.protocolVersion)) - for { - _ <- AcsInspection - .forEachVisibleActiveContract(domainId, state, parties, timestamp) { - case (contract, transferCounter) => - val activeContractE = - ActiveContract.create(domainIdForExport, contract, transferCounter)( - protocolVersion - ) + val ret = for { + _ <- AcsInspection + .forEachVisibleActiveContract(domainId, state, parties, timestamp) { + case (contract, transferCounter) => + val activeContractE = + ActiveContract.create(domainIdForExport, contract, transferCounter)( + protocolVersion + ) - activeContractE match { - case Left(e) => Left(InvariantIssue(domainId, contract.contractId, e.getMessage)) - case Right(bundle) => - bundle.writeDelimitedTo(outputStream) match { - case Left(errorMessage) => - Left(SerializationIssue(domainId, contract.contractId, errorMessage)) - case Right(_) => - outputStream.flush() - Right(()) - } - } + activeContractE match { + case Left(e) => + Left(InvariantIssue(domainId, contract.contractId, e.getMessage)) + case Right(bundle) => + bundle.writeDelimitedTo(outputStream) match { + case Left(errorMessage) => + Left(SerializationIssue(domainId, contract.contractId, errorMessage)) + case Right(_) => + outputStream.flush() + Right(()) + } + } - } - } yield () - case _ => - EitherTUtil.unit + } + } yield () + // re-enable journal cleaning after the dump + ret.thereafter { _ => + journalCleaningControl.enable(domainId) + } + case _ => + EitherTUtil.unit + } } + } def contractCount(domain: DomainAlias)(implicit traceContext: TraceContext): Future[Int] = { val state = syncDomainPersistentStateManager @@ -358,11 +406,6 @@ final class SyncStateInspection( closed.map(opener.tryOpen) } - def safeToPrune(beforeOrAt: CantonTimestamp, ledgerEndOffset: GlobalOffset)(implicit - traceContext: TraceContext - ): EitherT[Future, Pruning.LedgerPruningError, Option[GlobalOffset]] = pruningProcessor - .safeToPrune(beforeOrAt, ledgerEndOffset) - def findComputedCommitments( domain: DomainAlias, start: CantonTimestamp, diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/domain/DomainConnectionConfig.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/domain/DomainConnectionConfig.scala index 9969d52a22..0c867764fa 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/domain/DomainConnectionConfig.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/domain/DomainConnectionConfig.scala @@ -7,9 +7,9 @@ import cats.syntax.either.* import cats.syntax.option.* import cats.syntax.traverse.* import com.digitalasset.canton.ProtoDeserializationError.InvariantViolation +import com.digitalasset.canton.admin.participant.v0 import com.digitalasset.canton.config.DomainTimeTrackerConfig import com.digitalasset.canton.logging.pretty.{Pretty, PrettyPrinting} -import com.digitalasset.canton.participant.admin.v0 import com.digitalasset.canton.sequencing.{ GrpcSequencerConnection, SequencerConnection, diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/domain/DomainRegistry.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/domain/DomainRegistry.scala index 64364ffe95..3b0ddec1de 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/domain/DomainRegistry.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/domain/DomainRegistry.scala @@ -14,7 +14,7 @@ import com.digitalasset.canton.participant.store.SyncDomainPersistentState import com.digitalasset.canton.participant.sync.SyncServiceError.DomainRegistryErrorGroup import com.digitalasset.canton.participant.topology.TopologyComponentFactory import com.digitalasset.canton.protocol.StaticDomainParameters -import com.digitalasset.canton.sequencing.client.SequencerClient +import com.digitalasset.canton.sequencing.client.RichSequencerClient import com.digitalasset.canton.topology.DomainId import com.digitalasset.canton.topology.client.DomainTopologyClientWithInit import com.digitalasset.canton.tracing.TraceContext @@ -395,7 +395,7 @@ object DomainRegistryError extends DomainRegistryErrorGroup { trait DomainHandle extends AutoCloseable { /** Client to the domain's sequencer. */ - def sequencerClient: SequencerClient + def sequencerClient: RichSequencerClient def staticParameters: StaticDomainParameters diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/domain/DomainRegistryHelpers.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/domain/DomainRegistryHelpers.scala index 7f71f9d2cb..9899abc873 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/domain/DomainRegistryHelpers.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/domain/DomainRegistryHelpers.scala @@ -434,7 +434,7 @@ object DomainRegistryHelpers { domainId: DomainId, alias: DomainAlias, staticParameters: StaticDomainParameters, - sequencer: SequencerClient, + sequencer: RichSequencerClient, topologyClient: DomainTopologyClientWithInit, topologyFactory: TopologyComponentFactory, domainPersistentState: SyncDomainPersistentState, diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/domain/grpc/GrpcDomainRegistry.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/domain/grpc/GrpcDomainRegistry.scala index 8d08d801d5..714fe02ec4 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/domain/grpc/GrpcDomainRegistry.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/domain/grpc/GrpcDomainRegistry.scala @@ -29,7 +29,11 @@ import com.digitalasset.canton.participant.topology.{ } import com.digitalasset.canton.protocol.StaticDomainParameters import com.digitalasset.canton.sequencing.SequencerConnections -import com.digitalasset.canton.sequencing.client.{RecordingConfig, ReplayConfig, SequencerClient} +import com.digitalasset.canton.sequencing.client.{ + RecordingConfig, + ReplayConfig, + RichSequencerClient, +} import com.digitalasset.canton.time.Clock import com.digitalasset.canton.topology.* import com.digitalasset.canton.topology.client.DomainTopologyClientWithInit @@ -83,7 +87,7 @@ class GrpcDomainRegistry( override val domainId: DomainId, override val domainAlias: DomainAlias, override val staticParameters: StaticDomainParameters, - sequencer: SequencerClient, + sequencer: RichSequencerClient, override val topologyClient: DomainTopologyClientWithInit, override val topologyFactory: TopologyComponentFactory, override val domainPersistentState: SyncDomainPersistentState, @@ -92,7 +96,7 @@ class GrpcDomainRegistry( with FlagCloseableAsync with NamedLogging { - override val sequencerClient: SequencerClient = sequencer + override val sequencerClient: RichSequencerClient = sequencer override def loggerFactory: NamedLoggerFactory = GrpcDomainRegistry.this.loggerFactory override protected def closeAsync(): Seq[AsyncOrSyncCloseable] = { diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/ledger/api/StartableStoppableLedgerApiDependentServices.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/ledger/api/StartableStoppableLedgerApiDependentServices.scala index 78d3f56b32..05e8ae9658 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/ledger/api/StartableStoppableLedgerApiDependentServices.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/ledger/api/StartableStoppableLedgerApiDependentServices.scala @@ -4,13 +4,13 @@ package com.digitalasset.canton.participant.ledger.api import com.daml.grpc.adapter.ExecutionSequencerFactory +import com.digitalasset.canton.admin.participant.v0.{PackageServiceGrpc, PingServiceGrpc} import com.digitalasset.canton.concurrent.FutureSupervisor import com.digitalasset.canton.crypto.HashOps import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} import com.digitalasset.canton.networking.grpc.CantonMutableHandlerRegistry import com.digitalasset.canton.participant.ParticipantNodeParameters import com.digitalasset.canton.participant.admin.grpc.{GrpcPackageService, GrpcPingService} -import com.digitalasset.canton.participant.admin.v0.{PackageServiceGrpc, PingServiceGrpc} import com.digitalasset.canton.participant.admin.{AdminWorkflowServices, PackageService} import com.digitalasset.canton.participant.config.LocalParticipantConfig import com.digitalasset.canton.participant.sync.CantonSyncService diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/ledger/api/client/JavaDecodeUtil.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/ledger/api/client/JavaDecodeUtil.scala index bf15906de3..7e52b9528f 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/ledger/api/client/JavaDecodeUtil.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/ledger/api/client/JavaDecodeUtil.scala @@ -3,7 +3,6 @@ package com.digitalasset.canton.participant.ledger.api.client -import com.daml.ledger.api.v2.TransactionOuterClass.Transaction as JavaTransactionV2 import com.daml.ledger.javaapi.data.codegen.{ Contract, ContractCompanion, @@ -16,6 +15,8 @@ import com.daml.ledger.javaapi.data.{ Event, Transaction as JavaTransaction, TransactionTree, + TransactionTreeV2, + TransactionV2 as JavaTransactionV2, TreeEvent, } @@ -39,6 +40,9 @@ object JavaDecodeUtil { def flatToCreated(transaction: JavaTransaction): Seq[JavaCreatedEvent] = transaction.getEvents.iterator.asScala.collect { case e: JavaCreatedEvent => e }.toSeq + def flatToCreatedV2(transaction: JavaTransactionV2): Seq[JavaCreatedEvent] = + transaction.getEvents.iterator.asScala.collect { case e: JavaCreatedEvent => e }.toSeq + def decodeAllCreated[TC]( companion: ContractCompanion[TC, ?, ?] )(transaction: JavaTransaction): Seq[TC] = @@ -48,7 +52,7 @@ object JavaDecodeUtil { companion: ContractCompanion[TC, ?, ?] )(transaction: JavaTransactionV2): Seq[TC] = decodeAllCreatedFromEvents(companion)( - transaction.getEventsList.asScala.toSeq.map(Event.fromProtoEvent) + transaction.getEvents.iterator.asScala.toSeq ) def decodeAllCreatedFromEvents[TC]( @@ -66,6 +70,11 @@ object JavaDecodeUtil { )(transaction: JavaTransaction): Seq[ContractId[T]] = decodeAllArchivedFromEvents(companion)(transaction.getEvents.asScala.toSeq) + def decodeAllArchivedV2[T]( + companion: ContractCompanion[?, ?, T] + )(transaction: JavaTransactionV2): Seq[ContractId[T]] = + decodeAllArchivedFromEvents(companion)(transaction.getEvents.asScala.toSeq) + def decodeAllArchivedFromEvents[T]( companion: ContractCompanion[?, ?, T] )(events: Seq[Event]): Seq[ContractId[T]] = @@ -88,6 +97,9 @@ object JavaDecodeUtil { private def treeToCreated(transaction: TransactionTree): Seq[JavaCreatedEvent] = transaction.getEventsById.asScala.valuesIterator.collect { case e: JavaCreatedEvent => e }.toSeq + private def treeToCreatedV2(transaction: TransactionTreeV2): Seq[JavaCreatedEvent] = + transaction.getEventsById.asScala.valuesIterator.collect { case e: JavaCreatedEvent => e }.toSeq + def decodeAllCreatedTree[TC]( companion: ContractCompanion[TC, ?, ?] )(transaction: TransactionTree): Seq[TC] = @@ -96,11 +108,24 @@ object JavaDecodeUtil { a <- decodeCreated(companion)(created).toList } yield a + def decodeAllCreatedTreeV2[TC]( + companion: ContractCompanion[TC, ?, ?] + )(transaction: TransactionTreeV2): Seq[TC] = + for { + created <- treeToCreatedV2(transaction) + a <- decodeCreated(companion)(created).toList + } yield a + def decodeAllArchivedTree[TCid]( companion: ContractCompanion[?, TCid, ?] )(transaction: TransactionTree): Seq[TCid] = decodeAllArchivedTreeFromTreeEvents(companion)(transaction.getEventsById.asScala.toMap) + def decodeAllArchivedTreeV2[TCid]( + companion: ContractCompanion[?, TCid, ?] + )(transaction: TransactionTreeV2): Seq[TCid] = + decodeAllArchivedTreeFromTreeEvents(companion)(transaction.getEventsById.asScala.toMap) + def decodeAllArchivedTreeFromTreeEvents[TCid]( companion: ContractCompanion[?, TCid, ?] )(eventsById: Map[String, TreeEvent]): Seq[TCid] = diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/pruning/JournalGarbageCollector.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/pruning/JournalGarbageCollector.scala new file mode 100644 index 0000000000..f8212cd44f --- /dev/null +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/pruning/JournalGarbageCollector.scala @@ -0,0 +1,175 @@ +// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.participant.pruning + +import cats.Eval +import cats.syntax.foldable.* +import com.daml.nameof.NameOf.functionFullName +import com.digitalasset.canton.config.ProcessingTimeout +import com.digitalasset.canton.data.CantonTimestampSecond +import com.digitalasset.canton.lifecycle.{FlagCloseable, FutureUnlessShutdown, HasCloseContext} +import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} +import com.digitalasset.canton.participant.store.* +import com.digitalasset.canton.store.SequencerCounterTrackerStore +import com.digitalasset.canton.topology.DomainId +import com.digitalasset.canton.tracing.TraceContext +import com.digitalasset.canton.util.FutureUtil +import com.digitalasset.canton.util.Thereafter.syntax.* + +import java.util.concurrent.atomic.AtomicReference +import scala.concurrent.{ExecutionContext, Future, Promise} + +/** Canton synchronisation journals garbage collectors + * + * The difference between the normal ledger pruning feature and the journal garbage collector is that + * the ledger pruning is configured and invoked by the user, whereas the journal garbage collector runs + * periodically in the background, where the retention period is generally not configurable. + */ +private[participant] class JournalGarbageCollector( + requestJournalStore: RequestJournalStore, + sequencerCounterTrackerStore: SequencerCounterTrackerStore, + sortedReconciliationIntervalsProvider: SortedReconciliationIntervalsProvider, + acsCommitmentStore: AcsCommitmentStore, + acs: ActiveContractStore, + keyJournal: ContractKeyJournal, + submissionTrackerStore: SubmissionTrackerStore, + inFlightSubmissionStore: Eval[InFlightSubmissionStore], + domainId: DomainId, + override protected val timeouts: ProcessingTimeout, + protected val loggerFactory: NamedLoggerFactory, +)(implicit val executionContext: ExecutionContext) + extends JournalGarbageCollector.Scheduler { + + def observer( + traceContext: TraceContext + ): Unit = flush(traceContext) + + override protected def run()(implicit traceContext: TraceContext): FutureUnlessShutdown[Unit] = { + performUnlessClosingF(functionFullName) { + for { + safeToPruneTsO <- + AcsCommitmentProcessor.safeToPrune( + requestJournalStore, + sequencerCounterTrackerStore, + sortedReconciliationIntervalsProvider, + acsCommitmentStore, + inFlightSubmissionStore.value, + domainId, + checkForOutstandingCommitments = false, + ) + _ <- safeToPruneTsO.fold(Future.unit)(prune(_)) + } yield () + } + } + + private def prune(pruneTs: CantonTimestampSecond)(implicit + traceContext: TraceContext + ): Future[Unit] = { + logger.debug(s"Starting periodic background pruning of journals up to ${pruneTs}") + val acsDescription = s"Periodic ACS prune at $pruneTs:" + // Clean unused entries from the ACS + val acsF = performUnlessClosingF(acsDescription)( + FutureUtil.logOnFailure( + acs.prune(pruneTs.forgetRefinement), + acsDescription, + ) + ) + val journalFDescription = s"Periodic contract key journal prune at $pruneTs: " + // clean unused contract key journal entries + val journalF = performUnlessClosingF(journalFDescription)( + FutureUtil.logOnFailure( + keyJournal.prune(pruneTs.forgetRefinement), + journalFDescription, + ) + ) + val submissionTrackerStoreDescription = + s"Periodic submission tracker store prune at $pruneTs: " + // Clean unused entries from the submission tracker store + val submissionTrackerStoreF = performUnlessClosingF(submissionTrackerStoreDescription)( + FutureUtil.logOnFailure( + submissionTrackerStore.prune(pruneTs.forgetRefinement), + submissionTrackerStoreDescription, + ) + ) + Seq(acsF, journalF, submissionTrackerStoreF).sequence_.onShutdown(()) + } +} + +private[pruning] object JournalGarbageCollector { + private[pruning] abstract class Scheduler + extends NamedLogging + with FlagCloseable + with HasCloseContext { + + /** Manage internal state of the collector + * + * @param request if true, then the acs commitment processor completed a commitment period and suggested to kick off pruning + * @param locks number of locks that are currently active preventing pruning + * @param running if set, then a prune is currently running and the promise will be completed once it is done + */ + private case class State(requested: Boolean, locks: Int, running: Option[Promise[Unit]]) { + def incrementLock: State = copy(locks = locks + 1) + def decrementLock: State = copy(locks = Math.max(0, locks - 1)) + } + + private val state: AtomicReference[State] = new AtomicReference( + State(requested = false, locks = 0, running = None) + ) + + protected def run()(implicit traceContext: TraceContext): FutureUnlessShutdown[Unit] + protected implicit def executionContext: ExecutionContext + + private[pruning] def flush( + traceContext: TraceContext + ): Unit = { + // set request flag and kick off pruning if flag was not already set + if (!state.getAndUpdate(_.copy(requested = true)).requested) + doFlush()(traceContext) + } + + /** Temporarily turn off journal pruning (in order to download an ACS) + * + * This will add one lock. The lock will be removed when [[removeOneLock]] is called. + * Journal cleaning will resume once all locks are removed + */ + def addOneLock()(implicit traceContext: TraceContext): Future[Unit] = { + val old = state.getAndUpdate(_.incrementLock) + logger.debug(s"Journal garbage collection is now blocked with ${old.locks + 1} locks") + old.running.map(_.future).getOrElse(Future.unit) + } + + def removeOneLock()(implicit traceContext: TraceContext): Unit = { + val old = state.getAndUpdate(_.decrementLock) + logger.debug(s"Journal garbage collection has now ${old.locks - 1} locks") + if (old.locks == 1) { + doFlush() + } + } + + private def doFlush()(implicit traceContext: TraceContext): Unit = { + // if we are not closing and not running, then we can start a new prune + if (!isClosing) { + val currentState = state.getAndUpdate { + // start new process if idle and not blocked + case State(true, 0, None) => State(requested = false, 0, Some(Promise())) + // not enabled or already running, do nothing + case x => x + } + if (currentState.locks == 0 && currentState.running.isEmpty) { + // we are enabled and not running, so start a new prune + val runningF = run().onShutdown(()).thereafter { _ => + // once we've completed, see if we need to restart the next iteration immediately + val current = state.getAndUpdate(_.copy(running = None)) + current.running.foreach(_.success(())) + if (current.requested) { + doFlush()(traceContext) + } + } + FutureUtil.doNotAwait(runningF, "Periodic background journal pruning failed") + } + } + } + + } +} diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/pruning/PruneObserver.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/pruning/PruneObserver.scala deleted file mode 100644 index a1d3045d5c..0000000000 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/pruning/PruneObserver.scala +++ /dev/null @@ -1,117 +0,0 @@ -// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package com.digitalasset.canton.participant.pruning - -import cats.Eval -import cats.syntax.foldable.* -import com.daml.nameof.NameOf.functionFullName -import com.digitalasset.canton.config.ProcessingTimeout -import com.digitalasset.canton.data.{CantonTimestamp, CantonTimestampSecond} -import com.digitalasset.canton.lifecycle.{FlagCloseable, HasCloseContext} -import com.digitalasset.canton.logging.{NamedLoggerFactory, NamedLogging} -import com.digitalasset.canton.participant.store.* -import com.digitalasset.canton.store.SequencerCounterTrackerStore -import com.digitalasset.canton.time.Clock -import com.digitalasset.canton.topology.DomainId -import com.digitalasset.canton.tracing.TraceContext -import com.digitalasset.canton.util.FutureUtil -import com.digitalasset.canton.util.Thereafter.syntax.* - -import java.util.concurrent.atomic.{AtomicBoolean, AtomicReference} -import scala.concurrent.{ExecutionContext, Future} - -private[participant] class PruneObserver( - requestJournalStore: RequestJournalStore, - sequencerCounterTrackerStore: SequencerCounterTrackerStore, - sortedReconciliationIntervalsProvider: SortedReconciliationIntervalsProvider, - acsCommitmentStore: AcsCommitmentStore, - acs: ActiveContractStore, - keyJournal: ContractKeyJournal, - submissionTrackerStore: SubmissionTrackerStore, - inFlightSubmissionStore: Eval[InFlightSubmissionStore], - domainId: DomainId, - clock: Clock, - override protected val timeouts: ProcessingTimeout, - protected val loggerFactory: NamedLoggerFactory, -)(implicit executionContext: ExecutionContext) - extends NamedLogging - with FlagCloseable - with HasCloseContext { - - /** Stores the participant's local time when we last requested a pruning call - * (or [[com.digitalasset.canton.data.CantonTimestamp.MinValue]] if unknown) - */ - private val lastPruneRequest: AtomicReference[CantonTimestamp] = new AtomicReference( - CantonTimestamp.MinValue - ) - private val running: AtomicBoolean = new AtomicBoolean(false) - def observer( - traceContext: TraceContext - ): Unit = { - val localTs = clock.now - if (lastPruneRequest.updateAndGet(_.max(localTs)) == localTs) - doFlush(localTs)(traceContext) - } - - private def doFlush(localTs: CantonTimestamp)(implicit traceContext: TraceContext): Unit = { - // if we are not closing and not running, then we can start a new prune - if (!isClosing && running.compareAndSet(false, true)) { - val runningF = performUnlessClosingF(functionFullName) { - for { - safeToPruneTsO <- - AcsCommitmentProcessor.safeToPrune( - requestJournalStore, - sequencerCounterTrackerStore, - sortedReconciliationIntervalsProvider, - acsCommitmentStore, - inFlightSubmissionStore.value, - domainId, - checkForOutstandingCommitments = false, - ) - _ <- safeToPruneTsO.fold(Future.unit)(prune(_)) - } yield () - }.onShutdown(()).thereafter { _ => - // once we've completed, see if we need to restart the next iteration immediately - running.set(false) - val current = lastPruneRequest.get() - if (current > localTs) { - doFlush(current)(traceContext) - } - } - FutureUtil.doNotAwait(runningF, "Periodic background journal pruning failed") - } - } - - private def prune(pruneTs: CantonTimestampSecond)(implicit - traceContext: TraceContext - ): Future[Unit] = { - logger.debug(s"Starting periodic background pruning of journals up to ${pruneTs}") - val acsDescription = s"Periodic ACS prune at $pruneTs:" - // Clean unused entries from the ACS - val acsF = performUnlessClosingF(acsDescription)( - FutureUtil.logOnFailure( - acs.prune(pruneTs.forgetRefinement), - acsDescription, - ) - ) - val journalFDescription = s"Periodic contract key journal prune at $pruneTs: " - // clean unused contract key journal entries - val journalF = performUnlessClosingF(journalFDescription)( - FutureUtil.logOnFailure( - keyJournal.prune(pruneTs.forgetRefinement), - journalFDescription, - ) - ) - val submissionTrackerStoreDescription = - s"Periodic submission tracker store prune at $pruneTs: " - // Clean unused entries from the submission tracker store - val submissionTrackerStoreF = performUnlessClosingF(submissionTrackerStoreDescription)( - FutureUtil.logOnFailure( - submissionTrackerStore.prune(pruneTs.forgetRefinement), - submissionTrackerStoreDescription, - ) - ) - Seq(acsF, journalF, submissionTrackerStoreF).sequence_.onShutdown(()) - } -} diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/scheduler/ParticipantPruningScheduler.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/scheduler/ParticipantPruningScheduler.scala index 6653525855..6309bd326e 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/scheduler/ParticipantPruningScheduler.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/scheduler/ParticipantPruningScheduler.scala @@ -3,9 +3,9 @@ package com.digitalasset.canton.participant.scheduler +import com.digitalasset.canton.admin.pruning.v0 import com.digitalasset.canton.logging.NamedLoggerFactory import com.digitalasset.canton.participant.pruning.PruningProcessor -import com.digitalasset.canton.pruning.admin.v0 import com.digitalasset.canton.scheduler.{PruningSchedule, PruningScheduler, Scheduler, Schedulers} import com.digitalasset.canton.serialization.ProtoConverter import com.digitalasset.canton.serialization.ProtoConverter.ParsingResult diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/sync/CantonSyncService.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/sync/CantonSyncService.scala index f069964134..d37bfdcc1e 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/sync/CantonSyncService.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/sync/CantonSyncService.scala @@ -53,7 +53,10 @@ import com.digitalasset.canton.participant.Pruning.* import com.digitalasset.canton.participant.* import com.digitalasset.canton.participant.admin.* import com.digitalasset.canton.participant.admin.grpc.PruningServiceError -import com.digitalasset.canton.participant.admin.inspection.SyncStateInspection +import com.digitalasset.canton.participant.admin.inspection.{ + JournalGarbageCollectorControl, + SyncStateInspection, +} import com.digitalasset.canton.participant.admin.repair.RepairService import com.digitalasset.canton.participant.domain.* import com.digitalasset.canton.participant.event.RecordOrderPublisher @@ -394,8 +397,19 @@ class CantonSyncService( lazy val stateInspection = new SyncStateInspection( syncDomainPersistentStateManager, participantNodePersistentState, - pruningProcessor, parameters.processingTimeouts, + new JournalGarbageCollectorControl { + override def disable(domainId: DomainId)(implicit traceContext: TraceContext): Future[Unit] = + connectedDomainsMap + .get(domainId) + .map(_.addJournalGarageCollectionLock()) + .getOrElse(Future.unit) + override def enable(domainId: DomainId)(implicit traceContext: TraceContext): Unit = { + connectedDomainsMap + .get(domainId) + .foreach(_.removeJournalGarageCollectionLock()) + } + }, loggerFactory, ) @@ -406,7 +420,6 @@ class CantonSyncService( ): CompletionStage[PruningResult] = (withNewTrace("CantonSyncService.prune") { implicit traceContext => span => span.setAttribute("submission_id", submissionId) - pruneInternally(pruneUpToInclusive) .fold( err => PruningResult.NotPruned(err.code.asGrpcStatus(err)), diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/sync/SyncDomain.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/sync/SyncDomain.scala index 40a76d8a2d..4c0321e3c4 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/sync/SyncDomain.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/sync/SyncDomain.scala @@ -52,7 +52,7 @@ import com.digitalasset.canton.participant.protocol.transfer.TransferProcessingS import com.digitalasset.canton.participant.protocol.transfer.* import com.digitalasset.canton.participant.pruning.{ AcsCommitmentProcessor, - PruneObserver, + JournalGarbageCollector, SortedReconciliationIntervalsProvider, } import com.digitalasset.canton.participant.store.ActiveContractSnapshot.ActiveContractIdsChange @@ -73,7 +73,7 @@ import com.digitalasset.canton.platform.apiserver.execution.AuthorityResolver import com.digitalasset.canton.protocol.WellFormedTransaction.WithoutSuffixes import com.digitalasset.canton.protocol.* import com.digitalasset.canton.sequencing.* -import com.digitalasset.canton.sequencing.client.PeriodicAcknowledgements +import com.digitalasset.canton.sequencing.client.{PeriodicAcknowledgements, RichSequencerClient} import com.digitalasset.canton.sequencing.handlers.CleanSequencerCounterTracker import com.digitalasset.canton.sequencing.protocol.{ClosedEnvelope, Envelope, EventWithErrors} import com.digitalasset.canton.store.SequencedEventStore @@ -149,7 +149,7 @@ class SyncDomain( override def closingState: ComponentHealthState = ComponentHealthState.failed("Disconnected from domain") - private[canton] val sequencerClient = domainHandle.sequencerClient + private[canton] val sequencerClient: RichSequencerClient = domainHandle.sequencerClient val timeTracker: DomainTimeTracker = ephemeral.timeTracker val staticDomainParameters: StaticDomainParameters = domainHandle.staticParameters @@ -228,7 +228,8 @@ class SyncDomain( futureSupervisor, loggerFactory, ) - private val pruneObserver = new PruneObserver( + + private val journalGarbageCollector = new JournalGarbageCollector( persistent.requestJournalStore, persistent.sequencerCounterTrackerStore, sortedReconciliationIntervalsProvider, @@ -238,7 +239,6 @@ class SyncDomain( persistent.submissionTrackerStore, participantNodePersistentState.map(_.inFlightSubmissionStore), domainId, - clock, timeouts, loggerFactory, ) @@ -251,7 +251,7 @@ class SyncDomain( domainCrypto, sortedReconciliationIntervalsProvider, persistent.acsCommitmentStore, - pruneObserver.observer(_), + journalGarbageCollector.observer(_), pruningMetrics, staticDomainParameters.protocolVersion, timeouts, @@ -291,7 +291,7 @@ class SyncDomain( domainId, staticDomainParameters.protocolVersion, domainHandle.topologyClient, - domainHandle.sequencerClient, + sequencerClient, ) private val messageDispatcher: MessageDispatcher = @@ -316,6 +316,14 @@ class SyncDomain( metrics, ) + def addJournalGarageCollectionLock()(implicit + traceContext: TraceContext + ): Future[Unit] = journalGarbageCollector.addOneLock() + + def removeJournalGarageCollectionLock()(implicit + traceContext: TraceContext + ): Unit = journalGarbageCollector.removeOneLock() + def getTrafficControlState(implicit tc: TraceContext): Future[Option[MemberTrafficStatus]] = trafficStateController.getState @@ -883,8 +891,8 @@ class SyncDomain( domainCrypto, // Close the sequencer client so that the processors won't receive or handle events when // their shutdown is initiated. - domainHandle.sequencerClient, - pruneObserver, + sequencerClient, + journalGarbageCollector, acsCommitmentProcessor, transactionProcessor, transferOutProcessor, diff --git a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/topology/ParticipantTopologyDispatcher.scala b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/topology/ParticipantTopologyDispatcher.scala index 7d69052771..ee8c7de405 100644 --- a/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/topology/ParticipantTopologyDispatcher.scala +++ b/canton-3x/community/participant/src/main/scala/com/digitalasset/canton/participant/topology/ParticipantTopologyDispatcher.scala @@ -143,13 +143,18 @@ abstract class ParticipantTopologyDispatcherImplCommon[S <: SyncDomainPersistent domain: DomainAlias )(implicit traceContext: TraceContext): Unit = { domains.remove(domain) match { - case Some(outbox) => - outbox.foreach(_.close()) + case Some(outboxes) => + state.domainIdForAlias(domain).foreach(disconnectOutboxXes) + outboxes.foreach(_.close()) case None => logger.debug(s"Topology pusher already disconnected from $domain") } } + protected def disconnectOutboxXes(domainId: DomainId)(implicit + traceContext: TraceContext + ): Unit + override def awaitIdle(domain: DomainAlias, timeout: Duration)(implicit traceContext: TraceContext ): EitherT[FutureUnlessShutdown, DomainRegistryError, Boolean] = { @@ -308,6 +313,10 @@ class ParticipantTopologyDispatcher( )).run() } } + + override protected def disconnectOutboxXes(domainId: DomainId)(implicit + traceContext: TraceContext + ): Unit = () } class ParticipantTopologyDispatcherX( @@ -518,6 +527,13 @@ class ParticipantTopologyDispatcherX( } } + override protected def disconnectOutboxXes(domainId: DomainId)(implicit + traceContext: TraceContext + ): Unit = { + logger.debug("Clearing domain topology manager observers") + state.get(domainId).foreach(_.topologyManager.clearObservers()) + } + } /** Utility class to dispatch the initial set of onboarding transactions to a domain diff --git a/canton-3x/community/participant/src/test/scala/com/digitalasset/canton/participant/admin/GrpcTrafficControlServiceTest.scala b/canton-3x/community/participant/src/test/scala/com/digitalasset/canton/participant/admin/GrpcTrafficControlServiceTest.scala index db20d9f99f..c97bf3cfb1 100644 --- a/canton-3x/community/participant/src/test/scala/com/digitalasset/canton/participant/admin/GrpcTrafficControlServiceTest.scala +++ b/canton-3x/community/participant/src/test/scala/com/digitalasset/canton/participant/admin/GrpcTrafficControlServiceTest.scala @@ -3,10 +3,10 @@ package com.digitalasset.canton.participant.admin +import com.digitalasset.canton.admin.participant.v0.TrafficControlStateRequest import com.digitalasset.canton.config.RequireTypes.{NonNegativeLong, PositiveInt, PositiveLong} import com.digitalasset.canton.data.CantonTimestamp import com.digitalasset.canton.participant.admin.grpc.GrpcTrafficControlService -import com.digitalasset.canton.participant.admin.v0.TrafficControlStateRequest import com.digitalasset.canton.participant.sync.{CantonSyncService, SyncDomain} import com.digitalasset.canton.sequencing.protocol.SequencedEventTrafficState import com.digitalasset.canton.topology.DefaultTestIdentities diff --git a/canton-3x/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/MessageDispatcherTest.scala b/canton-3x/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/MessageDispatcherTest.scala index 67f7b0292e..8a6cfa9c5a 100644 --- a/canton-3x/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/MessageDispatcherTest.scala +++ b/canton-3x/community/participant/src/test/scala/com/digitalasset/canton/participant/protocol/MessageDispatcherTest.scala @@ -7,7 +7,6 @@ import cats.syntax.flatMap.* import cats.syntax.option.* import com.daml.metrics.api.MetricName import com.daml.nonempty.NonEmpty -import com.digitalasset.canton.config.ApiType.Grpc.prettyOfString import com.digitalasset.canton.crypto.provider.symbolic.SymbolicCrypto import com.digitalasset.canton.crypto.{ AsymmetricEncrypted, @@ -22,6 +21,7 @@ import com.digitalasset.canton.data.ViewType.{TransferInViewType, TransferOutVie import com.digitalasset.canton.data.* import com.digitalasset.canton.error.MediatorError import com.digitalasset.canton.lifecycle.{FutureUnlessShutdown, UnlessShutdown} +import com.digitalasset.canton.logging.pretty.PrettyUtil import com.digitalasset.canton.logging.{LogEntry, NamedLoggerFactory} import com.digitalasset.canton.metrics.MetricHandle.NoOpMetricsFactory import com.digitalasset.canton.participant.event.RecordOrderPublisher @@ -412,7 +412,7 @@ trait MessageDispatcherTest { when(rawCommitment.representativeProtocolVersion).thenReturn( AcsCommitment.protocolVersionRepresentativeFor(testedProtocolVersion) ) - when(rawCommitment.pretty).thenReturn(prettyOfString(_ => "test")) + when(rawCommitment.pretty).thenReturn(PrettyUtil.prettyOfString(_ => "test")) val commitment = SignedProtocolMessage.from(rawCommitment, testedProtocolVersion, dummySignature) diff --git a/canton-3x/community/participant/src/test/scala/com/digitalasset/canton/participant/pruning/JournalGarbageCollectorTest.scala b/canton-3x/community/participant/src/test/scala/com/digitalasset/canton/participant/pruning/JournalGarbageCollectorTest.scala new file mode 100644 index 0000000000..28d449a00d --- /dev/null +++ b/canton-3x/community/participant/src/test/scala/com/digitalasset/canton/participant/pruning/JournalGarbageCollectorTest.scala @@ -0,0 +1,93 @@ +// Copyright (c) 2023 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalasset.canton.participant.pruning + +import com.digitalasset.canton.config.ProcessingTimeout +import com.digitalasset.canton.lifecycle.FutureUnlessShutdown +import com.digitalasset.canton.logging.NamedLoggerFactory +import com.digitalasset.canton.tracing.TraceContext +import com.digitalasset.canton.{BaseTestWordSpec, HasExecutionContext} + +import java.util.concurrent.atomic.AtomicReference +import scala.concurrent.{ExecutionContext, Promise} + +class JournalGarbageCollectorTest extends BaseTestWordSpec with HasExecutionContext { + + private class TestScheduler extends JournalGarbageCollector.Scheduler() { + + val runningPromise = new AtomicReference[Option[Promise[Unit]]](None) + override def timeouts: ProcessingTimeout = JournalGarbageCollectorTest.this.timeouts + + override protected def run()(implicit + traceContext: TraceContext + ): FutureUnlessShutdown[Unit] = { + val ret = Promise[Unit]() + runningPromise.getAndSet(Some((ret))) match { + case Some(value) => fail("should not be running") + case None => + FutureUnlessShutdown.outcomeF(ret.future.map { _ => + runningPromise.set(None) + }) + } + } + + override protected implicit def executionContext: ExecutionContext = + JournalGarbageCollectorTest.this.directExecutionContext + + override protected def loggerFactory: NamedLoggerFactory = + JournalGarbageCollectorTest.this.loggerFactory + } + + "journal cleaning" should { + + "rerun if scheduled while running" in { + val t = new TestScheduler() + t.flush(TraceContext.empty) + val promise = t.runningPromise.get() + promise should not be None + // flush again + t.flush(TraceContext.empty) + // and flush again (multiple) + t.flush(TraceContext.empty) + // complete previous promise + promise.value.success(()) + // eventually, the second flush should have run + eventually() { + val cur = t.runningPromise.get() + cur should not be empty + // should be next run + cur shouldNot contain(promise) + } + // shut down in background + t.runningPromise.get().value.success(()) + } + "not run if blocked" in { + val t = new TestScheduler() + t.flush(TraceContext.empty) + val f1 = t.addOneLock() + val f2 = t.addOneLock() + f1.isCompleted shouldBe false + f2.isCompleted shouldBe false + // complete running job + t.runningPromise.get().value.success(()) + // eventually we should be done + eventually() { + t.runningPromise.get() shouldBe empty + f1.isCompleted shouldBe true + f2.isCompleted shouldBe true + } + // when rescheduled, shouldn't start + t.flush(TraceContext.empty) + t.runningPromise.get() shouldBe empty + // not start when removing one lock + t.removeOneLock() + t.runningPromise.get() shouldBe empty + // start when last lock is removed + t.removeOneLock() + t.runningPromise.get() should not be empty + t.runningPromise.get().value.success(()) + } + } + +}