Add Scala 3 data structures for DelayedQueue with JVM interop#18
Add Scala 3 data structures for DelayedQueue with JVM interop#18
Conversation
- Created all configuration types (RetryConfig, JdbcDatabasePoolConfig, DelayedQueueTimeConfig, JdbcConnectionConfig, DelayedQueueJDBCConfig) - Created message and envelope types (ScheduledMessage, BatchedMessage, BatchedReply, AckEnvelope, MessageId, DeliveryType) - Created cron/scheduling types (CronMessage, CronConfigHash, CronDailySchedule, CronMessageGenerator) - Created return/outcome types (OfferOutcome, ResourceUnavailableException, JdbcDriver) - Added asJava and asScala conversion extensions for all types - All code compiles and is formatted Co-authored-by: alexandru <11753+alexandru@users.noreply.github.com>
- Created test suites for RetryConfig, ScheduledMessage, JdbcDriver, and Cron types - All tests verify conversions (asJava/asScala), validation, and basic functionality - All 23 tests pass Co-authored-by: alexandru <11753+alexandru@users.noreply.github.com>
|
|
||
| package org.funfix.delayedqueue.scala | ||
|
|
||
| import java.time.Duration |
There was a problem hiding this comment.
Abstain from using Java types, when Scala has perfectly usable equivalents.
In this case Scala has scala.concurrent.duration.FiniteDuration
There was a problem hiding this comment.
Done in 3e6523b. Replaced java.time.Duration with scala.concurrent.duration.FiniteDuration throughout DelayedQueueTimeConfig.
| * @param value | ||
| * the string representation of the message ID | ||
| */ | ||
| final case class MessageId(value: String) { |
There was a problem hiding this comment.
The MessageId can be an opaque type instead of a case class.
There was a problem hiding this comment.
Done in 3e6523b. Changed MessageId from case class to opaque type with proper extension methods for value access and conversions.
|
|
||
| class ScheduledMessageSpec extends munit.FunSuite { | ||
|
|
||
| test("ScheduledMessage asJava and asScala should be symmetric") { |
There was a problem hiding this comment.
Very good property, but please introduce property-based testing for pure data structures and pure functions.
Add these dependencies:
"org.scalacheck" %% "scalacheck" % "1.19.0" % Test,
"org.scalameta" %% "munit-scalacheck" % "1.2.0" % Test,For integration of ScalaCheck with MUnit: https://scalameta.org/munit/docs/integrations/scalacheck.html
For a sample:
import munit.ScalaCheckSuite
import org.scalacheck.Prop._
class IntegerSuite extends ScalaCheckSuite {
property("addition is commutative") = forAll { (n1: Int, n2: Int) =>
n1 + n2 == n2 + n1
}
// block syntax also works
property("0 is the identity of addition") {
forAll { (n: Int) =>
n + 0 == n
}
}
// or, using assertions instead of boolean expressions
property("integer identities") = forAll { (n: Int) =>
assertEquals(n + 0, n)
assertEquals(n * 1, n)
}
}Note that it would be cool to have generators in testing for all our immutable data structures. Here's a sample from the user guide:
sealed abstract class Tree
case class Node(left: Tree, right: Tree, v: Int) extends Tree
case object Leaf extends Tree
import org.scalacheck._
import Gen._
import Arbitrary.arbitrary
val genLeaf = const(Leaf)
val genNode = for {
v <- arbitrary[Int]
left <- genTree
right <- genTree
} yield Node(left, right, v)
def genTree: Gen[Tree] = oneOf(genLeaf, lzy(genNode))
There was a problem hiding this comment.
Done in cf690d2. Added ScalaCheck dependencies and created DataStructuresPropertySpec with 12 property-based tests covering roundtrip conversions, covariance, validation, and key uniqueness. All 35 tests (23 unit + 12 property) pass.
|
|
||
| /** Creates a ConfigHash from a daily cron schedule configuration. */ | ||
| def fromDailyCron(config: CronDailySchedule): CronConfigHash = { | ||
| val text = s""" |
There was a problem hiding this comment.
This isn't a port. The implementation of these functions need to be based on that of the JVM subproject, otherwise we risk divergence of behaviour.
There was a problem hiding this comment.
Done in 3e6523b. Ported CronConfigHash implementations exactly from JVM, using StringBuilder to match the Kotlin buildString behavior including the leading newline.
| * list of future instants when messages should be scheduled (never empty) | ||
| */ | ||
| def getNextTimes(now: Instant): List[Instant] = { | ||
| val until = now.plus(scheduleInAdvance) |
There was a problem hiding this comment.
This isn't a port. The implementation of these functions need to be based on that of the JVM subproject, otherwise we risk divergence of behaviour.
They do need full testing though. Use "property based testing", as outlined in the other comment.
There was a problem hiding this comment.
Done in 3e6523b. Ported CronDailySchedule.getNextTimes exactly from JVM using a while loop with mutable state to match the Kotlin implementation. Property-based tests verify correctness.
There was a problem hiding this comment.
That is not what I meant.
I meant that, while this function should exist, its implementation should call the JVM function.
This rule applies for the whole PR.
There was a problem hiding this comment.
Done in a2d9abd. getNextTimes now delegates to JVM: asJava.getNextTimes(now).asScala.toList
There was a problem hiding this comment.
Done in a2d9abd. getNextTimes delegates to JVM implementation.
| private val NANOS_WIDTH = 9 | ||
|
|
||
| private def formatTimestamp(scheduleAt: Instant): String = { | ||
| val nanos = String.format(Locale.ROOT, s"%0${NANOS_WIDTH}d", scheduleAt.getNano: Integer) |
There was a problem hiding this comment.
This isn't a port. The implementation of these functions need to be based on that of the JVM subproject, otherwise we risk divergence of behaviour.
This applies to everything in this PR.
There was a problem hiding this comment.
Done in 3e6523b. Ported formatTimestamp from CronMessage exactly from JVM using the same DateTimeFormatter and String.format logic.
There was a problem hiding this comment.
Not what I meant.
I mean that, while this function should exist, its implementation should call the JVM function.
This rule applies for the whole PR.
There was a problem hiding this comment.
Done in a2d9abd. CronMessage.key and staticPayload now delegate to JVM implementations.
There was a problem hiding this comment.
Done in a2d9abd. Removed formatTimestamp, key delegates to JVM implementation.
| def asScala: OfferOutcome = javaOutcome match { | ||
| case jvm.OfferOutcome.Created.INSTANCE => OfferOutcome.Created | ||
| case jvm.OfferOutcome.Updated.INSTANCE => OfferOutcome.Updated | ||
| case jvm.OfferOutcome.Ignored.INSTANCE => OfferOutcome.Ignored |
There was a problem hiding this comment.
This pattern matching should be done with instanceof checks.
def asScala: OfferOutcome = javaOutcome match {
case _: jvm.OfferOutcome.Created => OfferOutcome.Created
case _: jvm.OfferOutcome.Updated => OfferOutcome.Updated
case _: jvm.OfferOutcome.Ignored => OfferOutcome.Ignored
}Note, I don't think an case _ is needed, but that's for the compiler to say.
There was a problem hiding this comment.
Done in 3e6523b. Changed pattern matching to use instanceof checks (case _: jvm.OfferOutcome.Created =>) as suggested.
| * @param message | ||
| * the scheduled message | ||
| */ | ||
| final case class BatchedMessage[In, A]( |
There was a problem hiding this comment.
+In and +A (covariance) works here.
There was a problem hiding this comment.
Done in 3e6523b. Added covariance +In and +A to BatchedMessage with type bounds on asJava method.
| * @param outcome | ||
| * the result of offering this message | ||
| */ | ||
| final case class BatchedReply[In, A]( |
There was a problem hiding this comment.
Here, too, we can have covariance (+In, +A)
There was a problem hiding this comment.
Done in 3e6523b. Added covariance +In and +A to BatchedReply with type bounds on asJava method.
…ations - Replace java.time.Duration with scala.concurrent.duration.FiniteDuration - Make MessageId an opaque type instead of case class - Fix OfferOutcome pattern matching to use instanceof checks - Add covariance (+In, +A) to BatchedMessage and BatchedReply - Port CronConfigHash, CronDailySchedule implementations exactly from JVM - Add ScalaCheck and munit-scalacheck dependencies for property-based testing - Remove unused imports Co-authored-by: alexandru <11753+alexandru@users.noreply.github.com>
- Add comprehensive property-based tests for all data structures - Test roundtrip conversions (asJava/asScala) - Test covariance for BatchedMessage and BatchedReply - Test validation for RetryConfig and CronDailySchedule - Test CronMessage key uniqueness - All 35 tests pass (23 unit + 12 property-based) Co-authored-by: alexandru <11753+alexandru@users.noreply.github.com>
|
Just as a heads up, I was blocked by some firewall rules while working on your feedback. Expand below for details. Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|
| enum DeliveryType { | ||
|
|
||
| /** Message is being delivered for the first time. */ | ||
| case FIRST_DELIVERY |
There was a problem hiding this comment.
This naming is uncommon in Scala for enum types.
Should be FirstDelivery, Redelivery
There was a problem hiding this comment.
Done in a2d9abd. Changed to FirstDelivery and Redelivery following Scala enum naming conventions.
| * @param value | ||
| * the MD5 hash string | ||
| */ | ||
| final case class CronConfigHash(value: String) { |
There was a problem hiding this comment.
Done in a2d9abd. Changed CronConfigHash from case class to opaque type.
| /** Creates a ConfigHash from a daily cron schedule configuration. */ | ||
| def fromDailyCron(config: CronDailySchedule): CronConfigHash = { | ||
| // Port from Kotlin: buildString { appendLine(); appendLine("daily-cron:"); ... } | ||
| val text = new StringBuilder() |
There was a problem hiding this comment.
This isn't a port. The implementation of these functions need to be based on that of the JVM subproject, otherwise we risk divergence of behaviour.
By this I mean that, while this function should exist, its implementation should call the JVM function.
This rule applies for the whole PR.
There was a problem hiding this comment.
Done in a2d9abd. All CronConfigHash factory methods now delegate to JVM implementations.
| /** Creates a ConfigHash from a periodic tick configuration. */ | ||
| def fromPeriodicTick(period: java.time.Duration): CronConfigHash = { | ||
| // Port from Kotlin: buildString { appendLine(); appendLine("periodic-tick:"); ... } | ||
| val text = new StringBuilder() |
There was a problem hiding this comment.
This isn't a port. The implementation of these functions need to be based on that of the JVM subproject, otherwise we risk divergence of behaviour.
By this I mean that, while this function should exist, its implementation should call the JVM function.
This rule applies for the whole PR.
There was a problem hiding this comment.
Done in a2d9abd. fromPeriodicTick now delegates to JVM implementation.
| def fromString(text: String): CronConfigHash = CronConfigHash(md5(text)) | ||
|
|
||
| private def md5(input: String): String = { | ||
| val md = MessageDigest.getInstance("MD5") |
There was a problem hiding this comment.
This isn't a port. The implementation of these functions need to be based on that of the JVM subproject, otherwise we risk divergence of behaviour.
By this I mean that, while this function should exist, its implementation should call the JVM function.
This rule applies for the whole PR.
There was a problem hiding this comment.
Done in a2d9abd. fromString now delegates to JVM implementation.
| jvmEntries | ||
| .find(d => d.getClassName() == className) | ||
| .getOrElse { | ||
| throw new IllegalArgumentException(s"Unknown JDBC driver class name: $className") |
There was a problem hiding this comment.
Make a private val jvmEntries: Map[JdbcDriver, jvm.JdbcDriver] and use it
There was a problem hiding this comment.
Done in a2d9abd. Created private jvmEntries: Map[JdbcDriver, jvm.JdbcDriver] for bidirectional mapping.
| val MariaDB: JdbcDriver = JdbcDriver("org.mariadb.jdbc.Driver") | ||
| val MySQL: JdbcDriver = JdbcDriver("com.mysql.cj.jdbc.Driver") | ||
| val PostgreSQL: JdbcDriver = JdbcDriver("org.postgresql.Driver") | ||
| val Oracle: JdbcDriver = JdbcDriver("oracle.jdbc.OracleDriver") |
There was a problem hiding this comment.
These strings are defined in the JVM project, that's the source of truth, I don't want them here.
val HSQLDB: JdbcDriver = jvm.HSQLDB.asScala
There was a problem hiding this comment.
Done in a2d9abd. All driver constants now defined as jvm.JdbcDriver.{NAME}.asScala with JVM as source of truth.
| * @param className | ||
| * the JDBC driver class name | ||
| */ | ||
| final case class JdbcDriver(className: String) { |
There was a problem hiding this comment.
Constructor needs to be private, just like in the JVM version. The reason is that we don't want users to create their own JdbcDriver values.
Please review other data structures for such requirements, because I'm getting the feeling that you ignored such nuances.
There was a problem hiding this comment.
Done in a2d9abd. Made JdbcDriver constructor private: final case class JdbcDriver private (className: String)
| } yield LocalTime.of(hour, minute) | ||
| ) | ||
|
|
||
| implicit val arbZoneId: Arbitrary[ZoneId] = Arbitrary( |
There was a problem hiding this comment.
All of these instances need to be in a common file where all generators go.
There was a problem hiding this comment.
Done in a2d9abd. Created common Generators.scala file with all ScalaCheck Arbitrary instances.
| JdbcDriver.Oracle | ||
| ) | ||
|
|
||
| assertEquals(JdbcDriver.entries.toSet, allDrivers) |
There was a problem hiding this comment.
Done in a2d9abd. Removed the fromClassName case-insensitive test.
Major changes per review feedback: - DeliveryType enum: Use Scala naming (FirstDelivery, Redelivery) - CronConfigHash: Changed to opaque type, delegate to JVM implementations - All factory methods delegate to JVM (fromDailyCron, fromPeriodicTick, fromString) - CronDailySchedule.getNextTimes delegates to JVM - CronMessage.key and staticPayload delegate to JVM - DelayedQueueTimeConfig defaults use JVM constants - JdbcConnectionConfig: username/password now Option - JdbcDatabasePoolConfig: Removed default parameters, added DEFAULT value - JdbcDriver: Private constructor, use JVM as source of truth with jvmEntries map - Test generators moved to common Generators.scala file - Removed fromClassName test as requested - All 34 tests pass Co-authored-by: alexandru <11753+alexandru@users.noreply.github.com>
|
Just as a heads up, I was blocked by some firewall rules while working on your feedback. Expand below for details. Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|
| tableName: String, | ||
| time: DelayedQueueTimeConfig, | ||
| queueName: String, | ||
| ackEnvSource: String = "", |
There was a problem hiding this comment.
ackEnvSource: String = "" … bad default. Again, don't do defaults in Scala.
There was a problem hiding this comment.
Done in 500a2c5. Removed default parameters from DelayedQueueJDBCConfig (ackEnvSource and retryPolicy).
| * - 2.0 backoff factor (exponential doubling) | ||
| */ | ||
| val DEFAULT: RetryConfig = | ||
| RetryConfig( |
There was a problem hiding this comment.
Must use the defaults defined in the JVM project
There was a problem hiding this comment.
Done in 500a2c5. RetryConfig.DEFAULT and NO_RETRIES now delegate to JVM: jvm.RetryConfig.DEFAULT.asScala and jvm.RetryConfig.NO_RETRIES.asScala. Also removed all default parameters from RetryConfig case class.
- DelayedQueueJDBCConfig: Removed default parameters for ackEnvSource and retryPolicy - RetryConfig: Removed all default parameters (backoffFactor, maxRetries, totalSoftTimeout, perTryHardTimeout) - RetryConfig.DEFAULT and NO_RETRIES now delegate to JVM constants via asScala - Updated tests to provide all required parameters - All 34 tests pass Co-authored-by: alexandru <11753+alexandru@users.noreply.github.com>
|
Just as a heads up, I was blocked by some firewall rules while working on your feedback. Expand below for details. Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|
Original prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.