Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import org.lfdecentralizedtrust.splice.http.v0.definitions.UpdateHistoryReassign
import org.lfdecentralizedtrust.splice.integration.tests.SpliceTests.SpliceTestConsoleEnvironment
import org.scalatest.concurrent.Eventually
import org.scalatest.matchers.should.Matchers
import org.scalatest.time.{Millis, Seconds, Span}
import org.scalatest.{Inspectors, LoneElement}

import scala.annotation.tailrec
Expand All @@ -34,7 +35,16 @@ class EventHistorySanityCheckPlugin(
): Unit = {
val initializedScans = environment.scans.local.filter(_.is_initialized)
if (initializedScans.nonEmpty) {
compareEventHistories(initializedScans)
// getEventHistory only serves events up to min(update, verdict) ingestion cursor
// (ScanEventStore.getCurrentMigrationCap), and verdict ingestion from the mediator lags
// behind update ingestion. At teardown this can hide even long-ingested updates, such as
// the DsoRules_AddSv exercise that compareEventHistories requires to appear in the founder
// history, so retry: each attempt re-fetches the histories until the cursors catch up.
eventually(compareEventHistories(initializedScans))(
PatienceConfig(timeout = Span(20, Seconds), interval = Span(500, Millis)),
implicitly[org.scalatest.enablers.Retrying[Unit]],
implicitly[org.scalactic.source.Position],
)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class SvFrontendIntegrationTest
},
)(
"logged in in the sv ui",
_ => find(id("app-title")).value.text should matchText("SUPER VALIDATOR OPERATIONS"),
_ => find(id("app-title")).value.text should matchText("Supervalidator Operations"),
)
}
}
Expand Down Expand Up @@ -1419,7 +1419,7 @@ class SvFrontendIntegrationTest
}
}

"NEW UI: Grant and Revoke Featured App Right" in { implicit env =>
"NEW UI: Grant, Update and Revoke Featured App Right" in { implicit env =>
val providerParty = sv3Backend.getDsoInfo().svParty
val providerPartyId = providerParty.toProtoPrimitive
val activityWeight = BigDecimal("2.5")
Expand All @@ -1435,23 +1435,8 @@ class SvFrontendIntegrationTest

clue("vote the grant request to execution before creating revoke request") {
val grantTrackingCid = eventually() {
val voteRequest: Contract[VoteRequest.ContractId, VoteRequest] = sv1Backend
.listVoteRequests()
.find { request =>
val requestCid = request.contractId.contractId
val trackingCid =
if (request.payload.trackingCid.isPresent) {
Some(request.payload.trackingCid.get.contractId)
} else {
None
}
requestCid == grantProposalContractId || trackingCid.contains(grantProposalContractId)
}
.getOrElse(
fail(
s"Could not find vote request for grant proposal contract id: $grantProposalContractId"
)
)
val voteRequest: Contract[VoteRequest.ContractId, VoteRequest] =
getVoteRequestForProposal(grantProposalContractId)

if (voteRequest.payload.trackingCid.isPresent) voteRequest.payload.trackingCid.get
else voteRequest.contractId
Expand All @@ -1474,6 +1459,38 @@ class SvFrontendIntegrationTest
}
}

val newActivityWeight = BigDecimal("3.0")

val updateProposalContractId = assertCreateProposal(
"SRARC_UpdateFeaturedAppRight",
"update-featured-app",
) { implicit webDriver =>
fillOutTextField("update-featured-app-partyId", providerPartyId)
selectFirstMuiOption("update-featured-app-rightCid-dropdown")
fillOutTextField("update-featured-app-activityWeight", newActivityWeight.toString)
fillOutTextField("update-featured-app-reason", "increasing weight")
}

clue("vote the update request to execution") {
val updateTrackingCid = eventually() {
val voteRequest = getVoteRequestForProposal(updateProposalContractId)
if (voteRequest.payload.trackingCid.isPresent) voteRequest.payload.trackingCid.get
else voteRequest.contractId
}

eventuallySucceeds() {
sv3Backend.castVote(updateTrackingCid, isAccepted = true, "", "")
}

eventually() {
val featuredAppRight = sv1ScanBackend.lookupFeaturedAppRight(providerParty)
featuredAppRight shouldBe a[Some[?]]
featuredAppRight.value.payload.activityWeight.toScala.map(
BigDecimal(_)
) shouldBe Some(newActivityWeight)
}
}

// Now create a Revoke proposal by selecting a contract ID from the provider's dropdown.
assertCreateProposal("SRARC_RevokeFeaturedAppRight", "revoke-featured-app") {
implicit webDriver =>
Expand Down Expand Up @@ -1655,6 +1672,29 @@ class SvFrontendIntegrationTest
}
}

def getVoteRequestForProposal(
proposalContractId: String
)(implicit env: SpliceTestConsoleEnvironment) = {
val voteRequest: Contract[VoteRequest.ContractId, VoteRequest] = sv1Backend
.listVoteRequests()
.find { request =>
val requestCid = request.contractId.contractId
val trackingCid =
if (request.payload.trackingCid.isPresent) {
Some(request.payload.trackingCid.get.contractId)
} else {
None
}
requestCid == proposalContractId || trackingCid.contains(proposalContractId)
}
.getOrElse(
fail(
s"Could not find vote request for proposal contract id: $proposalContractId"
)
)
voteRequest
}

def changeAction(actionName: String)(implicit webDriver: WebDriverType) = {
eventually() {
find(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import org.lfdecentralizedtrust.splice.sv.util.{SvOnboardingToken, SvUtil}
import scala.jdk.OptionConverters.*
import org.lfdecentralizedtrust.splice.sv.admin.api.client.commands.HttpSvPublicAppClient.SvOnboardingStatus
import org.lfdecentralizedtrust.splice.util.{SvTestUtil, WalletTestUtil}
import com.digitalasset.canton.console.CommandFailure
import com.digitalasset.canton.logging.SuppressionRule
import com.digitalasset.canton.topology.transaction.ParticipantPermission
import org.slf4j.event.Level
Expand Down Expand Up @@ -349,7 +350,11 @@ class SvOnboardingAddlIntegrationTest
}
clue("create a amulet again with actAs = DSO") {
withCommandRetryPolicy(_ => _ => false) {
assertThrowsAndLogsCommandFailures(
// Suppress at ERROR level only: background WARNs (e.g. the SV app failing to read the
// bft sequencers list while sv2's scan is unavailable) must not fail the log assertion.
loggerFactory.assertThrowsAndLogsSuppressing[CommandFailure](
SuppressionRule.LevelAndAbove(Level.ERROR)
)(
createAmulet(
sv1ValidatorBackend.participantClientWithAdminToken,
sv1UserId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import com.digitalasset.canton.discard.Implicits.DiscardOps
import org.apache.pekko.http.scaladsl.Http
import org.apache.pekko.http.scaladsl.model.{HttpRequest, HttpResponse, StatusCodes}
import org.apache.pekko.http.scaladsl.model.headers.{Authorization, OAuth2BearerToken}
import org.scalatest.concurrent.PatienceConfiguration
import org.scalatest.time.{Seconds, Span}
import org.slf4j.event.Level

import java.time.Duration
Expand Down Expand Up @@ -225,9 +227,12 @@ class WalletIntegrationTest

val tapsAfter = Range(0, 3).map(_ => Future(Try(aliceWalletClient.tap(10))))

// Wait for all futures to complete
val successfulTaps = (tapsBefore ++ tapsAfter).map(_.futureValue).count(_.isSuccess)
if (failedAcceptF.futureValue.isSuccess)
// Wait for all futures to complete. The stale accept forces the treasury to filter
// and retry batches, so under load this can exceed the default patience.
val patience = PatienceConfiguration.Timeout(Span(60, Seconds))
val successfulTaps =
(tapsBefore ++ tapsAfter).map(_.futureValue(patience)).count(_.isSuccess)
if (failedAcceptF.futureValue(patience).isSuccess)
fail("The AcceptTransferOffer action unexpectedly succeeded")

successfulTaps should be(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -243,30 +243,38 @@ class ScanVerdictIngestionService(
// Compute app activity records (before DB transaction).
// Records have verdictRowId = DUMMY_VERDICT_ROW_ID
// the store resolves actual row_ids during insertion.
(appActivityRecords, lastArchivedRoundO) <- appActivityComputationO match {
case Some(appActivityComputation) =>
for {
records <- appActivityComputation.computeActivities(summariesWithVerdicts).map {
_.flatMap { case (summary, _, recordO) =>
recordO.map(summary.sequencingTime -> _)
(appActivityRecords, firstActiveRoundO, lastArchivedRoundO) <-
appActivityComputationO match {
case Some(appActivityComputation) =>
val recordTimes =
verdicts.map(v => CantonTimestamp.tryFromProtoTimestamp(v.getRecordTime))
for {
records <- appActivityComputation.computeActivities(summariesWithVerdicts).map {
_.flatMap { case (summary, _, recordO) =>
recordO.map(summary.sequencingTime -> _)
}
}
}
lastArchivedRoundO <- verdicts
.map(v => CantonTimestamp.tryFromProtoTimestamp(v.getRecordTime))
.maxOption match {
case Some(maxRecordTime) =>
appActivityComputation.lookupLatestArchivedOpenMiningRound(maxRecordTime)
case None => Future.successful(None)
}
} yield (records, lastArchivedRoundO)
case None => Future.successful((Seq.empty, None))
}
firstActiveRoundO <- recordTimes.minOption match {
case Some(minRecordTime) =>
appActivityComputation.lookupActiveOpenMiningRound(minRecordTime)
case None => Future.successful(None)
}
lastArchivedRoundO <- recordTimes.maxOption match {
case Some(maxRecordTime) =>
appActivityComputation.lookupLatestArchivedOpenMiningRound(maxRecordTime)
case None => Future.successful(None)
}
} yield (records, firstActiveRoundO, lastArchivedRoundO)
case None => Future.successful((Seq.empty, None, None))
}

_ <- ensureVerdictsHaveTrafficSummaries(verdicts, summaryByTime)
_ <- store.insertVerdictsWithAppActivityRecords(
items,
appActivityRecords,
lastArchivedRoundO,
hasTrafficSummaries = summaryByTime.nonEmpty,
firstActiveRoundO = firstActiveRoundO,
lastArchivedRoundO = lastArchivedRoundO,
)
} yield {
val lastRecordTime = verdicts.lastOption
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,14 @@ class AppActivityComputation(
)(implicit tc: TraceContext): Future[Option[Long]] =
rewardsReferenceStore.lookupLatestArchivedOpenMiningRound(asOf)

/** The OpenMiningRound round number active at asOf, if the round data has been ingested. */
def lookupActiveOpenMiningRound(
asOf: CantonTimestamp
)(implicit tc: TraceContext): Future[Option[Long]] =
rewardsReferenceStore
.lookupActiveOpenMiningRounds(Seq(asOf))
.map(_.get(asOf).map { case (roundNumber, _) => roundNumber })

/** Compute app activity records for a batch of verdicts.
*
* Records are returned with verdictRowId = DUMMY_VERDICT_ROW_ID as a placeholder.
Expand Down Expand Up @@ -125,8 +133,12 @@ class AppActivityComputation(
}
case None =>
// Skip activity record computation as we don't have the necessary round data ingested.
// This can happen for freshly onboarded SVs, but is not
// expected to happen once the first activity record has been computed.
// This can happen for freshly onboarded SVs if the reward
// reference store does not have the data for any of the
// sequencingTime(s) in this batch.
// OTOH this cannot happen after ingestion starts because
// lookupActiveOpenMiningRounds blocks until the reference store
// has caught up to all the sequencingTime(s) in this batch.
logger.debug(
s"No round data found for sequencingTime=${summary.sequencingTime}, skipping activity record computation"
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -300,13 +300,16 @@ class DbAppActivityRecordStore(
/** Insert activity records and ensure the meta row exists.
* Creates the meta row when enough information is available to
* determine which rounds have complete activity, even when no
* activity records exist (e.g., no featured app providers).
* activity records exist (e.g., no featured app providers),
* but only if traffic-summaries could be obtained for this batch.
* On a fresh firstSV with no archived rounds, bootstraps round 0
* as complete.
*/
def insertAppActivityRecordsDBIO(
items: Seq[AppActivityRecordT],
firstRecordTimeMicros: Long,
hasTrafficSummaries: Boolean,
firstActiveRoundO: Option[Long] = None,
lastArchivedRoundO: Option[Long] = None,
)(implicit tc: TraceContext): DBIO[Unit] = {
val insertRecords =
Expand All @@ -316,15 +319,9 @@ class DbAppActivityRecordStore(
logger.info(s"Inserted ${items.size} app activity records.")
}

// earliestRound: the lowest round covered by this ingestion batch.
// - From activity records when present
// - From lastArchivedRound when no featured apps produced records
// - From bootstrap (-1) on a fresh firstSV with no archived rounds
val earliestRound = items
.map(_.roundNumber)
.minOption
.orElse(lastArchivedRoundO)
.orElse(if (isFirstSv) Some(-1L) else None)
// earliestRound: the oldest round open at the earliest record_time of this batch.
// or (-1) on firstSV, as it is expected to have complete data for the first round.
val earliestRound = if (isFirstSv) Some(-1L) else firstActiveRoundO

// lastArchived: the highest round archived as of this verdict batch.
// - From the caller when available
Expand All @@ -337,10 +334,12 @@ class DbAppActivityRecordStore(
for {
_ <- insertRecords
ensureResult <- earliestRound match {
case Some(earliest) =>
case Some(earliest) if hasTrafficSummaries =>
ensureMetaDBIO((firstRecordTimeMicros, earliest), lastArchived)
case None =>
// No archived rounds and not firstSV — skip meta creation.
case _ =>
// Either we have no rounds info and this is not firstSV,
// or we have not started obtaining the traffic summaries yet
// — skip meta creation.
// A later verdict batch will create it.
DBIO.successful(Resume: MetaCheckResult)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -479,12 +479,17 @@ class DbScanVerdictStore(
*
* @param items verdicts with transaction view constructors
* @param appActivityRecords activity records with placeholder verdictRowIds
* @param hasTrafficSummaries whether traffic summaries were fetched for this batch
* @param firstActiveRoundO the OpenMiningRound round active at the earliest
* record time of the batch
* @param lastArchivedRoundO the highest archived OpenMiningRound round as of the
* max record time of the batch
*/
def insertVerdictsWithAppActivityRecords(
items: NonEmptyList[(VerdictT, Long => Seq[TransactionViewT])],
appActivityRecords: Seq[(CantonTimestamp, AppActivityRecordT)],
hasTrafficSummaries: Boolean,
firstActiveRoundO: Option[Long] = None,
lastArchivedRoundO: Option[Long] = None,
)(implicit tc: TraceContext): Future[Unit] = {
import profile.api.jdbcActionExtensionMethods
Expand All @@ -498,6 +503,8 @@ class DbScanVerdictStore(
_ <- insertAppActivityRecordsDBIO(
resolvedAppActivityRecords,
items.head._1.recordTime.toMicros,
hasTrafficSummaries,
firstActiveRoundO,
lastArchivedRoundO,
)
} yield ()
Expand Down Expand Up @@ -545,12 +552,20 @@ class DbScanVerdictStore(
private def insertAppActivityRecordsDBIO(
items: Seq[AppActivityRecordT],
firstRecordTimeMicros: Long,
hasTrafficSummaries: Boolean,
firstActiveRoundO: Option[Long],
lastArchivedRoundO: Option[Long],
)(implicit tc: TraceContext): DBIO[Unit] =
appActivityRecordStoreO match {
case None => DBIO.successful(())
case Some(s) =>
s.insertAppActivityRecordsDBIO(items, firstRecordTimeMicros, lastArchivedRoundO)
s.insertAppActivityRecordsDBIO(
items,
firstRecordTimeMicros,
hasTrafficSummaries,
firstActiveRoundO,
lastArchivedRoundO,
)
}

private def afterFilters(
Expand Down
Loading