From a3f814ee4de488b9ec9ad5a9e41c7679a60d7f1d Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 8 Feb 2026 10:48:07 +0200 Subject: [PATCH 01/12] Initial Scala project --- .editorconfig | 19 + .gitignore | 18 +- .scalafmt.conf | 2 + build.sbt | 67 ++ .../org/funfix/delayedqueue/scala/hello.scala | 5 + project/build.properties | 1 + project/plugins.sbt | 6 + sbt | 925 ++++++++++++++++++ 8 files changed, 1040 insertions(+), 3 deletions(-) create mode 100644 .editorconfig create mode 100644 .scalafmt.conf create mode 100644 build.sbt create mode 100644 delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/hello.scala create mode 100644 project/build.properties create mode 100644 project/plugins.sbt create mode 100755 sbt diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..3df8fa4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,19 @@ +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true + +[*.scala] +indent_style = space +indent_size = 2 + +[*.{kt,kts,java}] +indent_style = space +indent_size = 4 + +[Makefile] +indent_style = tab + diff --git a/.gitignore b/.gitignore index f40e9db..af07269 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,24 @@ +# Gradle / Java / Gradle .gradle build/ !gradle/wrapper/gradle-wrapper.jar !**/src/main/**/build/ !**/src/test/**/build/ +### Kotlin ### +.kotlin + +### Scala ### +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ +.bsp/ +.metals/ +.bloop/ +project/project/ +project/metals.sbt + ### IntelliJ IDEA ### .idea *.iml @@ -14,9 +29,6 @@ out/ !**/src/main/**/out/ !**/src/test/**/out/ -### Kotlin ### -.kotlin - ### Eclipse ### .apt_generated .classpath diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..a04acb7 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,2 @@ +version = 3.10.6 +runner.dialect = scala213source3 diff --git a/build.sbt b/build.sbt new file mode 100644 index 0000000..34b126b --- /dev/null +++ b/build.sbt @@ -0,0 +1,67 @@ +import java.io.FileInputStream +import java.util.Properties + +ThisBuild / scalaVersion := "3.3.7" +ThisBuild / crossScalaVersions := Seq("2.13.18", scalaVersion.value) + +ThisBuild / resolvers ++= Seq(Resolver.mavenLocal) + +val publishLocalGradleDependencies = + taskKey[Unit]("Builds and publishes gradle dependencies") + +val props = settingKey[Properties]("Main project properties") +ThisBuild / props := { + val projectProperties = new Properties() + val rootDir = (ThisBuild / baseDirectory).value + val fis = new FileInputStream(s"$rootDir/gradle.properties") + projectProperties.load(fis) + projectProperties +} + +ThisBuild / version := { + val base = props.value.getProperty("project.version") + val isRelease = + sys.env + .get("BUILD_RELEASE") + .filter(_.nonEmpty) + .orElse(Option(System.getProperty("buildRelease"))) + .exists(it => it == "true" || it == "1" || it == "yes" || it == "on") + if (isRelease) base else s"$base-SNAPSHOT" +} + +Global / onChangedBuildSource := ReloadOnSourceChanges + +lazy val root = project + .in(file(".")) + .settings( + publish := {}, + publishLocal := {}, + publishLocalGradleDependencies := { + import scala.sys.process.* + val rootDir = (ThisBuild / baseDirectory).value + val command = Process( + "./gradlew" :: "publishToMavenLocal" :: Nil, + rootDir + ) + val log = streams.value.log + val exitCode = command ! log + if (exitCode != 0) { + sys.error(s"Command failed with exit code $exitCode") + } + } + ) + .aggregate(delayedqueueJVM) + +lazy val delayedqueue = crossProject(JVMPlatform) + .crossType(CrossType.Full) + .in(file("delayedqueue-scala")) + .settings( + name := "delayedqueue-scala" + ) + .jvmSettings( + libraryDependencies ++= Seq( + "org.funfix" % "delayedqueue-jvm" % version.value + ) + ) + +lazy val delayedqueueJVM = delayedqueue.jvm diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/hello.scala b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/hello.scala new file mode 100644 index 0000000..2666d82 --- /dev/null +++ b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/hello.scala @@ -0,0 +1,5 @@ +package org.funfix.delayedqueue.scala + +@main +def hello(): Unit = + println("Hello, world!") diff --git a/project/build.properties b/project/build.properties new file mode 100644 index 0000000..4d6c567 --- /dev/null +++ b/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.12.2 diff --git a/project/plugins.sbt b/project/plugins.sbt new file mode 100644 index 0000000..be8cfee --- /dev/null +++ b/project/plugins.sbt @@ -0,0 +1,6 @@ +addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.3.2") +addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.2") +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.20.2") +addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.10") +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.6") +addSbtPlugin("org.typelevel" % "sbt-tpolecat" % "0.5.2") diff --git a/sbt b/sbt new file mode 100755 index 0000000..33efa97 --- /dev/null +++ b/sbt @@ -0,0 +1,925 @@ +#!/usr/bin/env bash + +set +e +declare builtin_sbt_version="1.12.2" +declare -a residual_args +declare -a java_args +declare -a scalac_args +declare -a sbt_commands +declare -a sbt_options +declare -a print_version +declare -a print_sbt_version +declare -a print_sbt_script_version +declare -a shutdownall +declare -a original_args +declare java_cmd=java +declare java_version +declare init_sbt_version=_to_be_replaced +declare sbt_default_mem=1024 +declare -r default_sbt_opts="" +declare -r default_java_opts="-Dfile.encoding=UTF-8" +declare sbt_verbose= +declare sbt_debug= +declare build_props_sbt_version= +declare use_sbtn= +declare use_jvm_client= +declare no_server= +declare sbtn_command="$SBTN_CMD" +declare sbtn_version="1.12.1" +declare use_colors=1 +declare is_this_dir_sbt="" +declare hide_jdk_warnings=1 + +### ------------------------------- ### +### Helper methods for BASH scripts ### +### ------------------------------- ### + +# Bash reimplementation of realpath to return the absolute path +realpathish () { +( + TARGET_FILE="$1" + FIX_CYGPATH="$2" + + cd "$(dirname "$TARGET_FILE")" + TARGET_FILE=$(basename "$TARGET_FILE") + + COUNT=0 + while [ -L "$TARGET_FILE" -a $COUNT -lt 100 ] + do + TARGET_FILE=$(readlink "$TARGET_FILE") + cd "$(dirname "$TARGET_FILE")" + TARGET_FILE=$(basename "$TARGET_FILE") + COUNT=$(($COUNT + 1)) + done + + TARGET_DIR="$(pwd -P)" + if [ "$TARGET_DIR" == "/" ]; then + TARGET_FILE="/$TARGET_FILE" + else + TARGET_FILE="$TARGET_DIR/$TARGET_FILE" + fi + + # make sure we grab the actual windows path, instead of cygwin's path. + if [[ "x$FIX_CYGPATH" != "x" ]]; then + echo "$(cygwinpath "$TARGET_FILE")" + else + echo "$TARGET_FILE" + fi +) +} + +# Uses uname to detect if we're in the odd cygwin environment. +is_cygwin() { + local os=$(uname -s) + case "$os" in + CYGWIN*) return 0 ;; + MINGW*) return 0 ;; + MSYS*) return 0 ;; + *) return 1 ;; + esac +} + +# TODO - Use nicer bash-isms here. +CYGWIN_FLAG=$(if is_cygwin; then echo true; else echo false; fi) + +# This can fix cygwin style /cygdrive paths so we get the +# windows style paths. +cygwinpath() { + local file="$1" + if [[ "$CYGWIN_FLAG" == "true" ]]; then + echo $(cygpath -w $file) + else + echo $file + fi +} + +# Trim leading and trailing spaces from a string. +# Echos the new trimmed string. +trimString() { + local inputStr="$*" + local modStr="${inputStr#"${inputStr%%[![:space:]]*}"}" + modStr="${modStr%"${modStr##*[![:space:]]}"}" + echo "$modStr" +} + +declare -r sbt_bin_dir="$(dirname "$(realpathish "$0")")" +declare -r sbt_home="$(dirname "$sbt_bin_dir")" + +echoerr () { + echo 1>&2 "$@" +} +RED='\033[0;31m' +NC='\033[0m' # No Color +echoerr_error () { + if [[ $use_colors == "1" ]]; then + echoerr -e "[${RED}error${NC}] $@" + else + echoerr "[error] $@" + fi #" +} +vlog () { + [[ $sbt_verbose || $sbt_debug ]] && echoerr "$@" +} +dlog () { + [[ $sbt_debug ]] && echoerr "$@" +} + +jar_file () { + echo "$(cygwinpath "${sbt_home}/bin/sbt-launch.jar")" +} + +jar_url () { + local repo_base="$SBT_LAUNCH_REPO" + if [[ $repo_base == "" ]]; then + repo_base="https://repo1.maven.org/maven2" + fi + echo "$repo_base/org/scala-sbt/sbt-launch/$1/sbt-launch-$1.jar" +} + +download_url () { + local url="$1" + local jar="$2" + mkdir -p $(dirname "$jar") && { + if command -v curl > /dev/null; then + curl --silent -L "$url" --output "$jar" + elif command -v wget > /dev/null; then + wget --quiet -O "$jar" "$url" + else + echoerr "failed to download $url: Neither curl nor wget is available" + exit 2 + fi + } && [[ -f "$jar" ]] +} + +acquire_sbt_jar () { + local launcher_sv="$1" + if [[ "$launcher_sv" == "" ]]; then + if [[ "$init_sbt_version" != "_to_be_replaced" ]]; then + launcher_sv="$init_sbt_version" + else + launcher_sv="$builtin_sbt_version" + fi + fi + local user_home && user_home=$(findProperty user.home) + download_jar="${user_home:-$HOME}/.cache/sbt/boot/sbt-launch/$launcher_sv/sbt-launch-$launcher_sv.jar" + if [[ -f "$download_jar" ]]; then + sbt_jar="$download_jar" + else + sbt_url=$(jar_url "$launcher_sv") + dlog "downloading sbt launcher $launcher_sv" + download_url "$sbt_url" "${download_jar}.temp" + download_url "${sbt_url}.sha1" "${download_jar}.sha1" + if command -v shasum > /dev/null; then + if echo "$(cat "${download_jar}.sha1") ${download_jar}.temp" | shasum -c - > /dev/null; then + mv "${download_jar}.temp" "${download_jar}" + else + echoerr "failed to download launcher jar: $sbt_url (shasum mismatch)" + exit 2 + fi + else + mv "${download_jar}.temp" "${download_jar}" + fi + if [[ -f "$download_jar" ]]; then + sbt_jar="$download_jar" + else + echoerr "failed to download launcher jar: $sbt_url" + exit 2 + fi + fi +} + +acquire_sbtn () { + local sbtn_v="$1" + local user_home && user_home=$(findProperty user.home) + local p="${user_home:-$HOME}/.cache/sbt/boot/sbtn/$sbtn_v" + local target="$p/sbtn" + local archive_target= + local url= + local arch="x86_64" + if [[ "$OSTYPE" == "linux"* ]]; then + arch=$(uname -m) + if [[ "$arch" == "aarch64" ]] || [[ "$arch" == "x86_64" ]]; then + archive_target="$p/sbtn-${arch}-pc-linux-${sbtn_v}.tar.gz" + url="https://github.com/sbt/sbtn-dist/releases/download/v${sbtn_v}/sbtn-${arch}-pc-linux-${sbtn_v}.tar.gz" + else + echoerr_error "sbtn is not supported on $arch" + exit 2 + fi + elif [[ "$OSTYPE" == "darwin"* ]]; then + arch="universal" + archive_target="$p/sbtn-universal-apple-darwin-${sbtn_v}.tar.gz" + url="https://github.com/sbt/sbtn-dist/releases/download/v${sbtn_v}/sbtn-universal-apple-darwin-${sbtn_v}.tar.gz" + elif [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "win32" ]]; then + target="$p/sbtn.exe" + archive_target="$p/sbtn-x86_64-pc-win32-${sbtn_v}.zip" + url="https://github.com/sbt/sbtn-dist/releases/download/v${sbtn_v}/sbtn-x86_64-pc-win32-${sbtn_v}.zip" + else + echoerr_error "sbtn is not supported on $OSTYPE" + exit 2 + fi + + if [[ -f "$target" ]]; then + sbtn_command="$target" + else + dlog "downloading sbtn ${sbtn_v} for ${arch}" + download_url "$url" "$archive_target" + if [[ "$OSTYPE" == "linux-gnu"* ]] || [[ "$OSTYPE" == "darwin"* ]]; then + tar zxf "$archive_target" --directory "$p" + else + unzip "$archive_target" -d "$p" + fi + sbtn_command="$target" + fi +} + +# execRunner should be called only once to give up control to java +execRunner () { + # print the arguments one to a line, quoting any containing spaces + [[ $sbt_verbose || $sbt_debug ]] && echo "# Executing command line:" && { + for arg; do + if printf "%s\n" "$arg" | grep -q ' '; then + printf "\"%s\"\n" "$arg" + else + printf "%s\n" "$arg" + fi + done + echo "" + } + + if [[ "$CYGWIN_FLAG" == "true" ]]; then + # In cygwin we loose the ability to re-hook stty if exec is used + # https://github.com/sbt/sbt-launcher-package/issues/53 + "$@" + else + exec "$@" + fi +} + +addJava () { + dlog "[addJava] arg = '$1'" + java_args=( "${java_args[@]}" "$1" ) +} +addSbt () { + dlog "[addSbt] arg = '$1'" + sbt_commands=( "${sbt_commands[@]}" "$1" ) +} +addResidual () { + dlog "[residual] arg = '$1'" + residual_args=( "${residual_args[@]}" "$1" ) +} +addDebugger () { + addJava "-agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=$1" +} + +addMemory () { + dlog "[addMemory] arg = '$1'" + # evict memory related options + local xs=("${java_args[@]}") + java_args=() + for i in "${xs[@]}"; do + if ! [[ "${i}" == *-Xmx* ]] && ! [[ "${i}" == *-Xms* ]] && ! [[ "${i}" == *-Xss* ]] && ! [[ "${i}" == *-XX:MaxPermSize* ]] && ! [[ "${i}" == *-XX:MaxMetaspaceSize* ]] && ! [[ "${i}" == *-XX:ReservedCodeCacheSize* ]]; then + java_args+=("${i}") + fi + done + local ys=("${sbt_options[@]}") + sbt_options=() + for i in "${ys[@]}"; do + if ! [[ "${i}" == *-Xmx* ]] && ! [[ "${i}" == *-Xms* ]] && ! [[ "${i}" == *-Xss* ]] && ! [[ "${i}" == *-XX:MaxPermSize* ]] && ! [[ "${i}" == *-XX:MaxMetaspaceSize* ]] && ! [[ "${i}" == *-XX:ReservedCodeCacheSize* ]]; then + sbt_options+=("${i}") + fi + done + # a ham-fisted attempt to move some memory settings in concert + local mem=$1 + local codecache=$(( $mem / 8 )) + (( $codecache > 128 )) || codecache=128 + (( $codecache < 512 )) || codecache=512 + local class_metadata_size=$(( $codecache * 2 )) + if [[ -z $java_version ]]; then + java_version=$(jdk_version) + fi + + addJava "-Xms${mem}m" + addJava "-Xmx${mem}m" + addJava "-Xss4M" + addJava "-XX:ReservedCodeCacheSize=${codecache}m" + (( $java_version >= 8 )) || addJava "-XX:MaxPermSize=${class_metadata_size}m" +} + +addDefaultMemory() { + # if we detect any of these settings in ${JAVA_OPTS} or ${JAVA_TOOL_OPTIONS} or ${JDK_JAVA_OPTIONS} we need to NOT output our settings. + # The reason is the Xms/Xmx, if they don't line up, cause errors. + if [[ "${java_args[@]}" == *-Xmx* ]] || \ + [[ "${java_args[@]}" == *-Xms* ]] || \ + [[ "${java_args[@]}" == *-Xss* ]] || \ + [[ "${java_args[@]}" == *-XX:+UseCGroupMemoryLimitForHeap* ]] || \ + [[ "${java_args[@]}" == *-XX:MaxRAM* ]] || \ + [[ "${java_args[@]}" == *-XX:InitialRAMPercentage* ]] || \ + [[ "${java_args[@]}" == *-XX:MaxRAMPercentage* ]] || \ + [[ "${java_args[@]}" == *-XX:MinRAMPercentage* ]]; then + : + elif [[ "${JAVA_TOOL_OPTIONS}" == *-Xmx* ]] || \ + [[ "${JAVA_TOOL_OPTIONS}" == *-Xms* ]] || \ + [[ "${JAVA_TOOL_OPTIONS}" == *-Xss* ]] || \ + [[ "${JAVA_TOOL_OPTIONS}" == *-XX:+UseCGroupMemoryLimitForHeap* ]] || \ + [[ "${JAVA_TOOL_OPTIONS}" == *-XX:MaxRAM* ]] || \ + [[ "${JAVA_TOOL_OPTIONS}" == *-XX:InitialRAMPercentage* ]] || \ + [[ "${JAVA_TOOL_OPTIONS}" == *-XX:MaxRAMPercentage* ]] || \ + [[ "${JAVA_TOOL_OPTIONS}" == *-XX:MinRAMPercentage* ]] ; then + : + elif [[ "${JDK_JAVA_OPTIONS}" == *-Xmx* ]] || \ + [[ "${JDK_JAVA_OPTIONS}" == *-Xms* ]] || \ + [[ "${JDK_JAVA_OPTIONS}" == *-Xss* ]] || \ + [[ "${JDK_JAVA_OPTIONS}" == *-XX:+UseCGroupMemoryLimitForHeap* ]] || \ + [[ "${JDK_JAVA_OPTIONS}" == *-XX:MaxRAM* ]] || \ + [[ "${JDK_JAVA_OPTIONS}" == *-XX:InitialRAMPercentage* ]] || \ + [[ "${JDK_JAVA_OPTIONS}" == *-XX:MaxRAMPercentage* ]] || \ + [[ "${JDK_JAVA_OPTIONS}" == *-XX:MinRAMPercentage* ]] ; then + : + elif [[ "${sbt_options[@]}" == *-Xmx* ]] || \ + [[ "${sbt_options[@]}" == *-Xms* ]] || \ + [[ "${sbt_options[@]}" == *-Xss* ]] || \ + [[ "${sbt_options[@]}" == *-XX:+UseCGroupMemoryLimitForHeap* ]] || \ + [[ "${sbt_options[@]}" == *-XX:MaxRAM* ]] || \ + [[ "${sbt_options[@]}" == *-XX:InitialRAMPercentage* ]] || \ + [[ "${sbt_options[@]}" == *-XX:MaxRAMPercentage* ]] || \ + [[ "${sbt_options[@]}" == *-XX:MinRAMPercentage* ]] ; then + : + else + addMemory $sbt_default_mem + fi +} + +addSbtScriptProperty () { + if [[ "${java_args[@]}" == *-Dsbt.script=* ]]; then + : + else + sbt_script=$0 + # Use // to replace all spaces with %20. + sbt_script=${sbt_script// /%20} + addJava "-Dsbt.script=$sbt_script" + fi +} + +addJdkWorkaround () { + local is_25="$(expr $java_version "=" 25)" + if [[ "$hide_jdk_warnings" == "0" ]]; then + : + else + if [[ "$is_25" == "1" ]]; then + addJava "--sun-misc-unsafe-memory-access=allow" + addJava "--enable-native-access=ALL-UNNAMED" + fi + fi +} + +require_arg () { + local type="$1" + local opt="$2" + local arg="$3" + if [[ -z "$arg" ]] || [[ "${arg:0:1}" == "-" ]]; then + echoerr "$opt requires <$type> argument" + exit 1 + fi +} + +is_function_defined() { + declare -f "$1" > /dev/null +} + +# parses JDK version from the -version output line. +# 8 for 1.8.0_nn, 9 for 9-ea etc, and "no_java" for undetected +jdk_version() { + local result + local lines=$("$java_cmd" -Xms32M -Xmx32M -version 2>&1 | tr '\r' '\n') + local IFS=$'\n' + for line in $lines; do + if [[ (-z $result) && ($line = *"version \""*) ]] + then + local ver=$(echo $line | sed -e 's/.*version "\(.*\)"\(.*\)/\1/; 1q') + # on macOS sed doesn't support '?' + if [[ $ver = "1."* ]] + then + result=$(echo $ver | sed -e 's/1\.\([0-9]*\)\(.*\)/\1/; 1q') + else + result=$(echo $ver | sed -e 's/\([0-9]*\)\(.*\)/\1/; 1q') + fi + fi + done + if [[ -z $result ]] + then + result=no_java + fi + echo "$result" +} + +# Find the first occurrence of the given property name and returns its value by looking at: +# - properties set by command-line options, +# - JAVA_OPTS environment variable, +# - SBT_OPTS environment variable, +# - _JAVA_OPTIONS environment variable and +# - JAVA_TOOL_OPTIONS environment variable +# - JDK_JAVA_OPTIONS environment variable +# in that order. +findProperty() { + local -a java_opts_array + local -a sbt_opts_array + local -a _java_options_array + local -a java_tool_options_array + local -a jdk_java_options_array + read -a java_opts_array <<< "$JAVA_OPTS" + read -a sbt_opts_array <<< "$SBT_OPTS" + read -a _java_options_array <<< "$_JAVA_OPTIONS" + read -a java_tool_options_array <<< "$JAVA_TOOL_OPTIONS" + read -a jdk_java_options_array <<< "$JDK_JAVA_OPTIONS" + + local args_to_check=( + "${java_args[@]}" + "${java_opts_array[@]}" + "${sbt_opts_array[@]}" + "${_java_options_array[@]}" + "${java_tool_options_array[@]}" + "${jdk_java_options_array[@]}") + + for opt in "${args_to_check[@]}"; do + if [[ "$opt" == -D$1=* ]]; then + echo "${opt#-D$1=}" + return + fi + done +} + +# Extracts the preloaded directory from either -Dsbt.preloaded, -Dsbt.global.base or -Duser.home +# in that order. +getPreloaded() { + local preloaded && preloaded=$(findProperty sbt.preloaded) + [ "$preloaded" ] && echo "$preloaded" && return + + local global_base && global_base=$(findProperty sbt.global.base) + [ "$global_base" ] && echo "$global_base/preloaded" && return + + local user_home && user_home=$(findProperty user.home) + echo "${user_home:-$HOME}/.sbt/preloaded" +} + +syncPreloaded() { + local source_preloaded="$sbt_home/lib/local-preloaded/" + local target_preloaded="$(getPreloaded)" + if [[ "$init_sbt_version" == "" ]]; then + # FIXME: better $init_sbt_version detection + init_sbt_version="$(ls -1 "$source_preloaded/org/scala-sbt/sbt/")" + fi + [[ -f "$target_preloaded/org/scala-sbt/sbt/$init_sbt_version/" ]] || { + # lib/local-preloaded exists (This is optional) + [[ -d "$source_preloaded" ]] && { + command -v rsync >/dev/null 2>&1 && { + mkdir -p "$target_preloaded" + rsync --recursive --links --perms --times --ignore-existing "$source_preloaded" "$target_preloaded" || true + } + } + } +} + +# Detect that we have java installed. +checkJava() { + local required_version="$1" + # Now check to see if it's a good enough version + local good_enough="$(expr $java_version ">=" $required_version)" + if [[ "$java_version" == "" ]]; then + echoerr + echoerr "No Java Development Kit (JDK) installation was detected." + echoerr Go to https://adoptium.net/ etc and download. + echoerr + exit 1 + elif [[ "$good_enough" != "1" ]]; then + echoerr + echoerr "The Java Development Kit (JDK) installation you have is not up to date." + echoerr $script_name requires at least version $required_version+, you have + echoerr version $java_version + echoerr + echoerr Go to https://adoptium.net/ etc and download + echoerr a valid JDK and install before running $script_name. + echo + exit 1 + fi +} + +copyRt() { + local at_least_9="$(expr $java_version ">=" 9)" + if [[ "$at_least_9" == "1" ]]; then + # The grep for java9-rt-ext- matches the filename prefix printed in Export.java + java9_ext=$("$java_cmd" "${sbt_options[@]}" "${java_args[@]}" \ + -jar "$sbt_jar" --rt-ext-dir | grep java9-rt-ext- | tr -d '\r') + java9_rt=$(echo "$java9_ext/rt.jar") + vlog "[copyRt] java9_rt = '$java9_rt'" + if [[ ! -f "$java9_rt" ]]; then + mkdir -p "$java9_ext" + "$java_cmd" \ + "${sbt_options[@]}" \ + "${java_args[@]}" \ + -jar "$sbt_jar" \ + --export-rt \ + "${java9_rt}" + fi + addJava "-Dscala.ext.dirs=${java9_ext}" + fi +} + +detect_working_directory() { + if [[ -f ./build.sbt || -f ./project/build.properties ]]; then + is_this_dir_sbt=1 + fi +} + +# Confirm a user's intent if the current directory does not look like an sbt +# top-level directory and neither the --allow-empty option nor the "new" command was given. +checkWorkingDirectory() { + if [[ ! -n "$allow_empty" ]]; then + [[ -n "$is_this_dir_sbt" || -n "$sbt_new" ]] || { + echoerr_error "Neither build.sbt nor a 'project' directory in the current directory: $(pwd)" + echoerr_error "run 'sbt new', touch build.sbt, or run 'sbt --allow-empty'." + echoerr_error "" + echoerr_error "To opt out of this check, create ${config_home}/sbtopts with:" + echoerr_error "--allow-empty" + exit 1 + } + fi +} + +run() { + # Copy preloaded repo to user's preloaded directory + syncPreloaded + + # no jar? download it. + [[ -f "$sbt_jar" ]] || acquire_sbt_jar || { + exit 1 + } + + # TODO - java check should be configurable... + checkJava "8" + + # Java 9 support + copyRt + + # If we're in cygwin, we should use the windows config, and terminal hacks + if [[ "$CYGWIN_FLAG" == "true" ]]; then + stty -icanon min 1 -echo > /dev/null 2>&1 + addJava "-Djline.terminal=jline.UnixTerminal" + addJava "-Dsbt.cygwin=true" + fi + + detect_working_directory + if [[ $print_sbt_version ]]; then + execRunner "$java_cmd" -jar "$sbt_jar" "sbtVersion" | tail -1 | sed -e 's/\[info\]//g' + elif [[ $print_version ]]; then + if [[ -n "$is_this_dir_sbt" ]]; then + execRunner "$java_cmd" -jar "$sbt_jar" "sbtVersion" | tail -1 | sed -e 's/\[info\]/sbt version in this project:/g' + fi + echo "sbt runner version: $init_sbt_version" + echoerr "" + echoerr "[info] sbt runner (sbt-the-shell-script) is a runner to run any declared version of sbt." + echoerr "[info] Actual version of the sbt is declared using project/build.properties for each build." + elif [[ $shutdownall ]]; then + local sbt_processes=( $(jps -v | grep sbt-launch | cut -f1 -d ' ') ) + for procId in "${sbt_processes[@]}"; do + kill -9 $procId + done + echoerr "shutdown ${#sbt_processes[@]} sbt processes" + else + checkWorkingDirectory + # run sbt + execRunner "$java_cmd" \ + "${java_args[@]}" \ + "${sbt_options[@]}" \ + "${java_tool_options[@]}" \ + "${jdk_java_options[@]}" \ + -jar "$sbt_jar" \ + "${sbt_commands[@]}" \ + "${residual_args[@]}" + fi + + exit_code=$? + + # Clean up the terminal from cygwin hacks. + if [[ "$CYGWIN_FLAG" == "true" ]]; then + stty icanon echo > /dev/null 2>&1 + fi + exit $exit_code +} + +declare -ra noshare_opts=(-Dsbt.global.base=project/.sbtboot -Dsbt.boot.directory=project/.boot -Dsbt.ivy.home=project/.ivy) +declare -r sbt_opts_file=".sbtopts" +declare -r build_props_file="$(pwd)/project/build.properties" +declare -r etc_sbt_opts_file="/etc/sbt/sbtopts" +# this allows /etc/sbt/sbtopts location to be changed +declare machine_sbt_opts_file="${etc_sbt_opts_file}" +declare config_home="${XDG_CONFIG_HOME:-$HOME/.config}/sbt" +[[ -f "${config_home}/sbtopts" ]] && machine_sbt_opts_file="${config_home}/sbtopts" +[[ -f "$SBT_ETC_FILE" ]] && machine_sbt_opts_file="$SBT_ETC_FILE" +declare -r dist_sbt_opts_file="${sbt_home}/conf/sbtopts" +declare -r win_sbt_opts_file="${sbt_home}/conf/sbtconfig.txt" +declare sbt_jar="$(jar_file)" + +usage() { + cat < path to global settings/plugins directory (default: ~/.sbt) + --sbt-boot path to shared boot directory (default: ~/.sbt/boot in 0.11 series) + --sbt-cache path to global cache directory (default: operating system specific) + --ivy path to local Ivy repository (default: ~/.ivy2) + --mem set memory options (default: $sbt_default_mem) + --no-share use all local caches; no sharing + --no-global uses global caches, but does not use global ~/.sbt directory. + --jvm-debug Turn on JVM debugging, open at the given port. + --batch disable interactive mode + + # sbt version (default: from project/build.properties if present, else latest release) + --sbt-version use the specified version of sbt + --sbt-jar use the specified jar as the sbt launcher + + --java-home alternate JAVA_HOME + + # jvm options and output control + JAVA_OPTS environment variable, if unset uses "$default_java_opts" + .jvmopts if this file exists in the current directory, its contents + are appended to JAVA_OPTS + SBT_OPTS environment variable, if unset uses "$default_sbt_opts" + .sbtopts if this file exists in the current directory, its contents + are prepended to the runner args + /etc/sbt/sbtopts if this file exists, it is prepended to the runner args + -Dkey=val pass -Dkey=val directly to the java runtime + -J-X pass option -X directly to the java runtime + (-J is stripped) + +In the case of duplicated or conflicting options, the order above +shows precedence: JAVA_OPTS lowest, command line options highest. +EOM +} + +process_my_args () { + while [[ $# -gt 0 ]]; do + case "$1" in + -batch|--batch) exec + + -allow-empty|--allow-empty|-sbt-create|--sbt-create) allow_empty=true && shift ;; + + new|init) sbt_new=true && addResidual "$1" && shift ;; + + *) addResidual "$1" && shift ;; + esac + done + + # Now, ensure sbt version is used. + [[ "${sbt_version}XXX" != "XXX" ]] && addJava "-Dsbt.version=$sbt_version" +} + +## map over argument array. this is used to process both command line arguments and SBT_OPTS +map_args () { + local options=() + local commands=() + while [[ $# -gt 0 ]]; do + case "$1" in + -no-colors|--no-colors) options=( "${options[@]}" "-Dsbt.log.noformat=true" ) && shift ;; + -timings|--timings) options=( "${options[@]}" "-Dsbt.task.timings=true" "-Dsbt.task.timings.on.shutdown=true" ) && shift ;; + -traces|--traces) options=( "${options[@]}" "-Dsbt.traces=true" ) && shift ;; + --supershell=*) options=( "${options[@]}" "-Dsbt.supershell=${1:13}" ) && shift ;; + -supershell=*) options=( "${options[@]}" "-Dsbt.supershell=${1:12}" ) && shift ;; + -no-server|--no-server) options=( "${options[@]}" "-Dsbt.io.virtual=false" "-Dsbt.server.autostart=false" ) && shift ;; + --color=*) options=( "${options[@]}" "-Dsbt.color=${1:8}" ) && shift ;; + -color=*) options=( "${options[@]}" "-Dsbt.color=${1:7}" ) && shift ;; + -no-share|--no-share) options=( "${options[@]}" "${noshare_opts[@]}" ) && shift ;; + -no-global|--no-global) options=( "${options[@]}" "-Dsbt.global.base=$(pwd)/project/.sbtboot" ) && shift ;; + -ivy|--ivy) require_arg path "$1" "$2" && options=( "${options[@]}" "-Dsbt.ivy.home=$2" ) && shift 2 ;; + -sbt-boot|--sbt-boot) require_arg path "$1" "$2" && options=( "${options[@]}" "-Dsbt.boot.directory=$2" ) && shift 2 ;; + -sbt-dir|--sbt-dir) require_arg path "$1" "$2" && options=( "${options[@]}" "-Dsbt.global.base=$2" ) && shift 2 ;; + -debug|--debug) commands=( "${commands[@]}" "-debug" ) && shift ;; + -debug-inc|--debug-inc) options=( "${options[@]}" "-Dxsbt.inc.debug=true" ) && shift ;; + *) options=( "${options[@]}" "$1" ) && shift ;; + esac + done + declare -p options + declare -p commands +} + +process_args () { + while [[ $# -gt 0 ]]; do + case "$1" in + -h|-help|--help) usage; exit 1 ;; + -v|-verbose|--verbose) sbt_verbose=1 && shift ;; + -V|-version|--version) print_version=1 && shift ;; + --numeric-version) print_sbt_version=1 && shift ;; + --script-version) print_sbt_script_version=1 && shift ;; + shutdownall) shutdownall=1 && shift ;; + -d|-debug|--debug) sbt_debug=1 && addSbt "-debug" && shift ;; + -client|--client) use_sbtn=1 && shift ;; + --server) use_sbtn=0 && shift ;; + --jvm-client) use_sbtn=0 && use_jvm_client=1 && addSbt "--client" && shift ;; + --no-hide-jdk-warnings) hide_jdk_warnings=0 && shift ;; + + -mem|--mem) require_arg integer "$1" "$2" && addMemory "$2" && shift 2 ;; + -jvm-debug|--jvm-debug) require_arg port "$1" "$2" && addDebugger $2 && shift 2 ;; + -batch|--batch) exec = 2 )); then + if [[ "$use_sbtn" == "0" ]]; then + echo "false" + else + echo "true" + fi + elif ( (( $sbtBinaryV_1 >= 1 )) && (( $sbtBinaryV_2 >= 4 )) ); then + if [[ "$use_sbtn" == "1" ]]; then + echo "true" + else + echo "false" + fi + else + echo "false" + fi +} + +runNativeClient() { + vlog "[debug] running native client" + detectNativeClient + [[ -f "$sbtn_command" ]] || acquire_sbtn "$sbtn_version" || { + exit 1 + } + for i in "${!original_args[@]}"; do + if [[ "${original_args[i]}" = "--client" ]]; then + unset 'original_args[i]' + fi + done + + if [[ "$OSTYPE" == "cygwin" ]] || [[ "$OSTYPE" == "msys" ]] || [[ "$OSTYPE" == "win32" ]]; then + sbt_script="$0.bat" + else + sbt_script="$0" + fi + sbt_script=${sbt_script/ /%20} + execRunner "$sbtn_command" "--sbt-script=$sbt_script" "${original_args[@]}" +} + +original_args=("$@") + +sbt_file_opts=() + +# Pull in the machine-wide settings configuration. +if [[ -f "$machine_sbt_opts_file" ]]; then + sbt_file_opts+=($(loadConfigFile "$machine_sbt_opts_file")) +else + # Otherwise pull in the default settings configuration. + [[ -f "$dist_sbt_opts_file" ]] && sbt_file_opts+=($(loadConfigFile "$dist_sbt_opts_file")) +fi + +# Pull in the project-level config file, if it exists (highest priority, overrides machine/dist). +[[ -f "$sbt_opts_file" ]] && sbt_file_opts+=($(loadConfigFile "$sbt_opts_file")) + +# Prepend sbtopts so command line args appear last and win for duplicate properties. +if (( ${#sbt_file_opts[@]} > 0 )); then + set -- "${sbt_file_opts[@]}" "$@" +fi + +# Pull in the project-level java config, if it exists. +[[ -f ".jvmopts" ]] && export JAVA_OPTS="$JAVA_OPTS $(loadConfigFile .jvmopts)" + +# Pull in default JAVA_OPTS +[[ -z "${JAVA_OPTS// }" ]] && export JAVA_OPTS="$default_java_opts" + +[[ -f "$build_props_file" ]] && loadPropFile "$build_props_file" + +java_args=($JAVA_OPTS) +sbt_options0=(${SBT_OPTS:-$default_sbt_opts}) +java_tool_options=($JAVA_TOOL_OPTIONS) +jdk_java_options=($JDK_JAVA_OPTIONS) +if [[ "$SBT_NATIVE_CLIENT" == "true" ]]; then + use_sbtn=1 +fi + +# Split SBT_OPTS into options/commands +miniscript=$(map_args "${sbt_options0[@]}") && eval "${miniscript/options/sbt_options}" && \ +eval "${miniscript/commands/sbt_additional_commands}" + +# Combine command line options/commands and commands from SBT_OPTS +miniscript=$(map_args "$@") && eval "${miniscript/options/cli_options}" && eval "${miniscript/commands/cli_commands}" +args1=( "${cli_options[@]}" "${cli_commands[@]}" "${sbt_additional_commands[@]}" ) + +# process the combined args, then reset "$@" to the residuals +process_args "${args1[@]}" +vlog "[sbt_options] $(declare -p sbt_options)" + +# Handle --script-version before native client so it works on sbt 2.x project dirs (#8711) +if [[ $print_sbt_script_version ]]; then + echo "$init_sbt_version" + exit 0 +fi + +if [[ "$(isRunNativeClient)" == "true" ]]; then + set -- "${residual_args[@]}" + argumentCount=$# + runNativeClient +else + java_version="$(jdk_version)" + vlog "[process_args] java_version = '$java_version'" + addDefaultMemory + addSbtScriptProperty + addJdkWorkaround + set -- "${residual_args[@]}" + argumentCount=$# + run +fi From a7faf2cb649f5fd59df3e8e79180632dfafc47f4 Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 8 Feb 2026 11:16:40 +0200 Subject: [PATCH 02/12] Init Scala project --- .agents/skills/cats-effect-io/SKILL.md | 44 +++++ .../references/cats-effect-io.md | 96 +++++++++++ .agents/skills/cats-effect-resource/SKILL.md | 29 ++++ .../references/resource.md | 134 +++++++++++++++ .agents/skills/cats-mtl-typed-errors/SKILL.md | 33 ++++ .../references/custom-error-types.md | 161 ++++++++++++++++++ .claude/skills/cats-effect-io | 1 + .claude/skills/cats-effect-resource | 1 + .claude/skills/cats-mtl-typed-errors | 1 + .github/workflows/build.yaml | 41 ++++- .scalafmt.conf | 15 +- build.sbt | 61 ++++--- .../delayedqueue/scala/HelloSuite.scala | 9 + project/plugins.sbt | 3 + 14 files changed, 599 insertions(+), 30 deletions(-) create mode 100644 .agents/skills/cats-effect-io/SKILL.md create mode 100644 .agents/skills/cats-effect-io/references/cats-effect-io.md create mode 100644 .agents/skills/cats-effect-resource/SKILL.md create mode 100644 .agents/skills/cats-effect-resource/references/resource.md create mode 100644 .agents/skills/cats-mtl-typed-errors/SKILL.md create mode 100644 .agents/skills/cats-mtl-typed-errors/references/custom-error-types.md create mode 120000 .claude/skills/cats-effect-io create mode 120000 .claude/skills/cats-effect-resource create mode 120000 .claude/skills/cats-mtl-typed-errors create mode 100644 delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/HelloSuite.scala diff --git a/.agents/skills/cats-effect-io/SKILL.md b/.agents/skills/cats-effect-io/SKILL.md new file mode 100644 index 0000000..11cf776 --- /dev/null +++ b/.agents/skills/cats-effect-io/SKILL.md @@ -0,0 +1,44 @@ +--- +name: cats-effect-io +description: Scala functional programming with Cats Effect IO and typeclasses. Use for wrapping side effects, modeling purity, choosing Sync/Async/Temporal/Concurrent, handling blocking I/O, and composing resources, fibers, and concurrency safely. +--- + +# Cats Effect IO (Scala) + +## Quick start +- Treat every side effect as an effect value: return `IO[A]`, `SyncIO[A]`, or `F[A]` with `F[_]: Sync`/`Async`/`Temporal` as needed. +- Wrap Java blocking calls with `IO.blocking` or `IO.interruptible` (or `Sync[F].blocking`/`interruptible`). +- Use `Resource` to acquire/release resources and `IOApp` for program entry points. +- Prefer structured concurrency (`parTraverse`, `parMapN`, `background`, `Supervisor`) over manual fiber management. +- Read `references/cats-effect-io.md` for concepts, recipes, and FAQ guidance. +- For deeper `Resource` guidance, use the `cats-effect-resource` skill (install: `npx skills add https://github.com/alexandru/skills --skill cats-effect-resource`). + +## Workflow +1. Classify side effects and choose the effect type: `IO` directly or polymorphic `F[_]` with the smallest required Cats Effect typeclass (`Sync`, `Async`, `Temporal`, `Concurrent`). +2. Wrap side-effectful code using `IO(...)`, `IO.blocking`, `IO.interruptible`, or `IO.async` (or their `Sync`/`Async` equivalents). +3. Manage resources with `Resource` or `bracket` and keep acquisition/release inside effects. +4. Compose effects with `flatMap`/for-comprehensions and collection combinators (`traverse`, `parTraverse`). +5. Use concurrency primitives (`Ref`, `Deferred`, `Queue`, `Semaphore`, `Supervisor`) and structured concurrency to avoid fiber leaks. + +## Side-effect rules (apply to `IO`, `SyncIO`, and to `F[_]: Sync/Async`) +- All side-effectful functions must return results wrapped in `IO` (or `F[_]` with Cats Effect typeclasses). +- Side-effects include all non-determinism (call sites are not referentially transparent): + - Any I/O (files, sockets, console, databases). + - `Instant.now()`, `Random.nextInt()`. + - Any read from shared mutable state (the read itself is the side effect). + - Returning mutable data structures (for example, `Array[Int]`). + +## Blocking I/O rules +- Java blocking methods must be wrapped in `IO.blocking` or `IO.interruptible` (or `Sync[F].blocking`/`interruptible`) so they run on the blocking pool. +- Prefer `IO.interruptible` for methods that may throw `InterruptedException` or `IOException`, but not for resource disposal. +- Use `IO.blocking` for cleanup/disposal (`Closeable#close`, `AutoCloseable#close`). + +## Output expectations +- Make side effects explicit in signatures (`IO`/`SyncIO` or `F[_]: Sync/Async`); the guidance here applies equally to concrete `IO` and polymorphic `F[_]`. +- Use the smallest typeclass constraint that supports the needed operations. +- Keep effects as values; do not execute effects in constructors or top-level vals. + +## References +- Load `references/cats-effect-io.md` for documentation summary and patterns. +- For concrete samples, read `references/cats-effect-io.md`. +- Use the `cats-effect-resource` skill for Resource-specific workflows and patterns (install: `npx skills add https://github.com/alexandru/skills --skill cats-effect-resource`). diff --git a/.agents/skills/cats-effect-io/references/cats-effect-io.md b/.agents/skills/cats-effect-io/references/cats-effect-io.md new file mode 100644 index 0000000..e1ca11b --- /dev/null +++ b/.agents/skills/cats-effect-io/references/cats-effect-io.md @@ -0,0 +1,96 @@ +# Cats Effect IO and Typeclasses (Scala) + +Sources: +- https://typelevel.org/cats-effect/docs/tutorial +- https://typelevel.org/cats-effect/docs/concepts +- https://typelevel.org/cats-effect/docs/recipes +- https://typelevel.org/cats-effect/docs/faq + +## Core ideas +- **Effects as values**: `IO[A]` (or `F[A]`) describes side effects; nothing runs until the effect is evaluated. +- **Fibers** are lightweight threads; use structured concurrency (`parMapN`, `parTraverse`) instead of manual `start`/`join`. +- **Cancelation** is cooperative and always runs finalizers; use `Resource` to ensure cleanup under success, error, or cancel. +- **Asynchronous vs synchronous**: `IO.async` uses callbacks; `IO.delay`/`IO.blocking`/`IO.interruptible` use synchronous execution. + +## Blocking and interruptibility +- `IO.blocking` (or `Sync[F].blocking`) moves blocking JVM calls onto the blocking pool. +- `IO.interruptible` allows cancelation via thread interruption when the underlying API supports it. +- Many `java.io` reads ignore interruption; use explicit cancelation protocols when available. + +## Resource safety +- Prefer `Resource` over manual `try/finally` for acquisition/release. +- Use `Resource.fromAutoCloseable` for simple `AutoCloseable` lifecycles; use `Resource.make` when you need custom release handling. + +## Common recipes +- **Background work**: use `Supervisor` for start-and-forget fibers with safe cleanup. +- **Effectful loops**: use `traverse`/`traverse_` and `parTraverse` for sequencing or parallelism. +- **Shared state**: use `Ref`, `Deferred`, and other std primitives (avoid mutable state). + +## API samples (IO and F[_]) + +Side effects as values (IO vs F[_]): +```scala +import cats.effect.{IO, Sync} + +def nowIO: IO[Long] = IO(java.time.Instant.now().toEpochMilli) + +def nowF[F[_]: Sync]: F[Long] = + Sync[F].delay(java.time.Instant.now().toEpochMilli) +``` + +Polymorphic side effects: +```scala +import cats.effect.Sync + +def readEnv[F[_]: Sync](key: String): F[Option[String]] = + Sync[F].delay(sys.env.get(key)) +``` + +Blocking vs interruptible: +```scala +import cats.effect.{IO, Sync} + +import java.io.FileInputStream + +def readByteIO(path: String): IO[Int] = + IO.blocking(new FileInputStream(path)).bracket { in => + IO.interruptible(in.read()) + } { in => + IO.blocking(in.close()) + } + +val blockingCall: IO[Unit] = IO.blocking { + java.nio.file.Files.list(java.nio.file.Paths.get("/tmp")).close() +} + +def interruptibleSleep[F[_]: Sync]: F[Unit] = + Sync[F].interruptible(Thread.sleep(250)) +``` + +Resource usage: +```scala +import cats.effect.{IO, Resource} +import java.io.FileInputStream + +def inputStream(path: String): Resource[IO, FileInputStream] = + Resource.fromAutoCloseable(IO.blocking(new FileInputStream(path))) + +def readFirstByte(path: String): IO[Int] = + inputStream(path).use(in => IO.interruptible(in.read())) +``` + +Structured concurrency: +```scala +import cats.effect.{IO, IOApp} +import cats.syntax.all._ + +object ParallelExample extends IOApp.Simple { + val run: IO[Unit] = + (IO.println("A"), IO.println("B")).parTupled.void +} +``` + +## FAQ highlights +- If an `IO` is created but not composed, it does not run; compiler warnings can help catch this. +- `IO(...)` may run on a blocking thread in some optimized cases; this is normal. +- Starvation warnings often indicate accidental blocking without `IO.blocking`. diff --git a/.agents/skills/cats-effect-resource/SKILL.md b/.agents/skills/cats-effect-resource/SKILL.md new file mode 100644 index 0000000..4325314 --- /dev/null +++ b/.agents/skills/cats-effect-resource/SKILL.md @@ -0,0 +1,29 @@ +--- +name: cats-effect-resource +description: Scala resource lifecycle management with Cats Effect `Resource` and `IO`. Use when defining safe acquisition/release, composing resources (including parallel acquisition), or designing resource-safe APIs and cancellation behavior for files, streams, pools, clients, and background fibers. +--- + +# Cats Effect Resource (Scala) + +## Quick start +- Model each resource with `Resource.make` or `Resource.fromAutoCloseable` and keep release idempotent. +- Compose resources with `flatMap`, `mapN`, `parMapN`, or helper constructors; expose `Resource[F, A]` from APIs. +- Use `Resource` at lifecycle boundaries and call `.use` only at the program edges. +- Read `references/resource.md` for patterns, best practices, and API notes. + +## Workflow +1. Identify acquisition, use, and release steps; decide if acquisition is blocking. +2. Implement a `Resource[F, A]` constructor using the smallest needed typeclass. +3. Compose resources into higher-level resources and keep finalizers minimal. +4. Decide how cancelation and errors should influence release logic. +5. Run with `.use` at the boundary (IOApp, service startup) and avoid leaking raw `A`. + +## Usage guidance +- Prefer `Resource` over `try/finally` or `bracket` when composition and cancelation safety matter. +- Use `IO.blocking` (or `Sync[F].blocking`) for acquisition and release when calling blocking JVM APIs. +- For background fibers, use `Resource` or `Supervisor` to ensure cleanup on cancelation. + +## References +- Load `references/resource.md` for API details, patterns, and examples. +- For Kotlin/Arrow parallels, see the `arrow-resource` skill. +- Install this skill with `npx skills add https://github.com/alexandru/skills --skill cats-effect-resource`. diff --git a/.agents/skills/cats-effect-resource/references/resource.md b/.agents/skills/cats-effect-resource/references/resource.md new file mode 100644 index 0000000..a2e1998 --- /dev/null +++ b/.agents/skills/cats-effect-resource/references/resource.md @@ -0,0 +1,134 @@ +# Cats Effect Resource (Scala) - Practical Guide + +Sources: +- https://typelevel.org/cats-effect/docs/std/resource +- https://github.com/typelevel/cats-effect/blob/series/3.x/kernel/shared/src/main/scala/cats/effect/kernel/Resource.scala + +## Table of Contents +- [Core model](#core-model) +- [Core APIs](#core-apis) +- [When to use Resource](#when-to-use-resource) +- [Patterns](#patterns) +- [Cancelation and error behavior](#cancelation-and-error-behavior) +- [Interop and blocking](#interop-and-blocking) +- [Checklist](#checklist) + +## Core model +- `Resource[F, A]` encodes acquisition and release with a `use` phase. +- Release runs on success, error, or cancelation. +- Acquisition and finalizers are sequenced and run in a controlled scope; release is LIFO. + +## Core APIs +- `Resource.make(acquire)(release)` for custom lifecycle. +- `Resource.fromAutoCloseable` for `AutoCloseable` lifecycles. +- `Resource.eval` to lift an effect into a resource. +- `.use` to run the resource and ensure release. +- `map`, `flatMap`, `mapN`, `parMapN`, `parZip` to compose resources. + +## When to use Resource +- You need safe cleanup under cancelation. +- You need to compose resources and guarantee LIFO release. +- You want an API that makes lifecycle explicit and testable. + +## Patterns + +### 1) Resource constructors +Prefer functions that return `Resource[F, A]`: + +```scala +import cats.effect.{Resource, Sync} + +final class UserProcessor { + def start(): Unit = () + def shutdown(): Unit = () +} + +def userProcessor[F[_]: Sync]: Resource[F, UserProcessor] = + Resource.make(Sync[F].delay { new UserProcessor().tap(_.start()) })(p => + Sync[F].delay(p.shutdown()) + ) +``` + +### 2) Composing resources + +```scala +import cats.effect.{Resource, Sync} +import cats.syntax.all._ + +final class DataSource { def connect(): Unit = (); def close(): Unit = () } +final class Service(ds: DataSource, up: UserProcessor) + +def dataSource[F[_]: Sync]: Resource[F, DataSource] = + Resource.make(Sync[F].delay { new DataSource().tap(_.connect()) })(ds => + Sync[F].delay(ds.close()) + ) + +def service[F[_]: Sync]: Resource[F, Service] = + (dataSource[F], userProcessor[F]).mapN(new Service(_, _)) +``` + +### 3) Parallel acquisition + +```scala +import cats.effect.{Resource, Sync} +import cats.syntax.all._ + +def servicePar[F[_]: Sync]: Resource[F, Service] = + (dataSource[F], userProcessor[F]).parMapN(new Service(_, _)) +``` + +### 4) File input stream + +```scala +import cats.effect.{IO, Resource} + +import java.io.FileInputStream + +def inputStream(path: String): Resource[IO, FileInputStream] = + Resource.fromAutoCloseable(IO.blocking(new FileInputStream(path))) +``` + +### 5) Database pool + per-connection resource + +```scala +import cats.effect.{Resource, Sync} + +import javax.sql.DataSource + +def pool[F[_]: Sync]: Resource[F, DataSource] = ??? + +def connection[F[_]: Sync](ds: DataSource): Resource[F, java.sql.Connection] = + Resource.make(Sync[F].blocking(ds.getConnection))(c => + Sync[F].blocking(c.close()) + ) +``` + +### 6) Acquire in a loop +Use `Resource.make` per element and compose with `traverse`/`parTraverse`: + +```scala +import cats.effect.{Resource, Sync} +import cats.syntax.all._ + +def acquireOne[F[_]: Sync](id: String): Resource[F, Handle] = ??? + +def acquireAll[F[_]: Sync](ids: List[String]): Resource[F, List[Handle]] = + ids.traverse(acquireOne[F]) +``` + +## Cancelation and error behavior +- Finalizers run on success, error, or cancelation. +- If finalizers can fail, decide whether to log, suppress, or raise secondary errors. +- Keep finalizers idempotent and minimal to avoid cascading failures during release. + +## Interop and blocking +- Wrap blocking acquisition or release in `blocking` to avoid compute starvation. +- Prefer `Resource.fromAutoCloseable` for Java interop; use `make` for custom release. +- If the API supports cooperative cancellation, combine it with `Resource` to ensure cleanup. + +## Checklist +- Expose `Resource[F, A]` in public constructors. +- Keep release idempotent and tolerant of partial failures. +- Use `parMapN` only for independent resources. +- Avoid calling `.use` except at lifecycle boundaries. +- Use `IO.blocking`/`Sync[F].blocking` for blocking JVM APIs. diff --git a/.agents/skills/cats-mtl-typed-errors/SKILL.md b/.agents/skills/cats-mtl-typed-errors/SKILL.md new file mode 100644 index 0000000..471ff5c --- /dev/null +++ b/.agents/skills/cats-mtl-typed-errors/SKILL.md @@ -0,0 +1,33 @@ +--- +name: cats-mtl-typed-errors +description: Scala typed errors with Cats MTL Raise/Handle and allow/rescue. Use for designing custom domain error types without EitherT, while keeping Cats Effect and ecosystem composition. Covers Scala 2/3 syntax and IO-only or F[_] usage. +--- + +# Cats MTL Typed Errors (Scala) + +## Quick start +- Define a domain error type; it may or may not extend Throwable depending on context. +- Use Cats MTL `Raise[F, E]` in functions that can raise errors. +- Use `Handle.allow`/`rescue` (Scala 3) or `Handle.allowF` (Scala 2) to introduce a scoped error capability and handle it like try/catch. +- Prefer Cats MTL over `IO[Either[E, A]]` and avoid `EitherT[IO, E, A]`; pure functions returning `Either[E, A]` are fine at API boundaries. +- `F[_]` is optional: you can write `IO`-specific code or keep `F[_]` for polymorphism, depending on the project. + +## Workflow +1. Model domain errors as sealed ADTs (Scala 2) or enums (Scala 3) +2. For effectful code that can raise errors, require `Raise[F, E]` (and `Monad[F]` or `Applicative[F]`). +3. Raise errors with `.raise` and return successful values with `pure`. +4. At a boundary, use `Handle.allow` (Scala 3) or `Handle.allowF` (Scala 2) to create a scope where raises are valid. +5. Close the scope with `.rescue` to handle each error case explicitly. +6. Keep Cats Effect resource and concurrency semantics intact by staying in the monofunctor error channel. + +## Patterns to apply +- **Typed errors in signatures**: treat the error type parameter `E` as the checked-exception channel in the function signature. +- **Scoped error capabilities**: require `Raise[F, E]` in functions that can fail; use `Handle[F, E]` when you also need to recover. +- **Scala 3 ergonomics**: prefer `using` and context functions with `allow`; type inference is significantly better. +- **Scala 2 compatibility**: use `allowF` and explicit implicit parameters; expect more braces and explicit types. +- **Interop with pure code**: use pure `Either[E, A]` for parsing/validation and lift into `F` where needed. +- **Avoid transformer stacks**: do not reach for `EitherT` just to get a typed error channel; Cats MTL provides the capability without the stack. +- **Avoid sealed-on-sealed inheritance**: model error hierarchies with composition (wrapper case classes), not sealed inheritance chains. + +## References +- Load `references/custom-error-types.md` for detailed guidance, Scala 2/3 syntax, and rationale from the Typelevel article. diff --git a/.agents/skills/cats-mtl-typed-errors/references/custom-error-types.md b/.agents/skills/cats-mtl-typed-errors/references/custom-error-types.md new file mode 100644 index 0000000..0287ce5 --- /dev/null +++ b/.agents/skills/cats-mtl-typed-errors/references/custom-error-types.md @@ -0,0 +1,161 @@ +# Custom Error Types Using Cats Effect and MTL + +Sources: +- https://typelevel.org/blog/2025/09/02/custom-error-types.html +- https://typelevel.org/cats-mtl/mtl-classes/raise.html + +## Table of Contents +- [Summary](#summary) +- [Core concepts](#core-concepts) +- [Avoid sealed inheritance chains](#avoid-sealed-inheritance-chains) +- [Prefer Cats MTL over EitherT](#prefer-cats-mtl-over-eithert) +- [Capability-based typed errors](#capability-based-typed-errors) +- [Notes on F[_] vs IO](#notes-on-f_-vs-io) +- [Behavior and safety](#behavior-and-safety) +- [Test prompts](#test-prompts) +- [When to use what](#when-to-use-what) + +## Summary +- Cats MTL 1.6.0 adds lightweight syntax for user-defined error types without monad transformer stacks. +- Keeps the single Throwable error channel for Cats Effect while allowing scoped typed errors via capabilities. +- Preserves compositional behavior with Cats Effect, Fs2, Http4s, and other libraries. + +## Core concepts +- **Monofunctor effects** (`IO[A]`) have a single error channel (`Throwable`). +- **Typed errors** provide explicit, domain-specific errors in the function signature, similar to Java checked exceptions. +- **Cats MTL capabilities** express what a scope can do (raise/handle errors) via implicit evidence. +- **Scoped error handling** with `allow`/`rescue` acts like `try`/`catch` for typed errors. + +## Avoid sealed inheritance chains +Avoid having a sealed error type inherit from another sealed error type. Prefer composition so each error ADT stays focused and can be wrapped by the outer domain error. + +Wrong: +```scala +sealed trait DomainError + +sealed trait ParseError extends DomainError +object ParseError { + final case class MissingRequiredField(field: String) extends ParseError +} +``` + +Prefer composition (wrapping): +```scala +sealed trait DomainError +object DomainError { + final case class Parse(error: ParseError) extends DomainError +} + +sealed trait ParseError +object ParseError { + final case class MissingRequiredField(field: String) extends ParseError +} +``` + +## Prefer Cats MTL over EitherT +- `IO[Either[E, A]]` forces pervasive `Either`-level plumbing and awkward integration with effect libraries. +- `EitherT` stacks can lead to unintuitive concurrency/resource behavior; avoid in most effectful code. +- Cats MTL provides typed errors without introducing a transformer stack. +- Pure functions returning `Either[E, A]` are acceptable, especially at pure boundaries. + +## Capability-based typed errors +Use `Raise[F, E]` for functions that can raise errors and `Handle[F, E]` when you can also recover. +Whether the custom error type extends Throwable is a contextual choice; using Throwable introduces mutability concerns. + +Scala 3 (context functions and using): +```scala +import cats.Monad +import cats.effect.IO +import cats.mtl.syntax.all.* +import cats.mtl.{Handle, Raise} +import cats.syntax.all.* + +enum ParseError: + case UnclosedBracket + case MissingSemicolon + case Other(msg: String) + +def parse[F[_]](input: String)(using Raise[F, ParseError], Monad[F]): F[Result] = + if missingBracket then + ParseError.UnclosedBracket.raise[F, Result] + else if missingSemicolon then + ParseError.MissingSemicolon.raise + else + result.pure[F] + +val program: IO[Unit] = Handle.allow[ParseError]: + for + x <- parse[IO](inputX) + y <- parse(inputY) + _ <- IO.println(s"successfully parsed $x and $y") + yield () +.rescue: + case ParseError.UnclosedBracket => + IO.println("you didn't close your brackets") + case ParseError.MissingSemicolon => + IO.println("you missed your semicolons very much") + case ParseError.Other(msg) => + IO.println(s"error: $msg") +``` + +Scala 2 (explicit implicits and allowF): +```scala +import cats.Monad +import cats.effect.IO +import cats.mtl.syntax.all._ +import cats.mtl.{Handle, Raise} +import cats.syntax.all._ + +sealed trait ParseError extends Product with Serializable +object ParseError { + case object UnclosedBracket extends ParseError + case object MissingSemicolon extends ParseError + final case class Other(msg: String) extends ParseError +} + +def parse[F[_]](input: String)(implicit r: Raise[F, ParseError], m: Monad[F]): F[Result] = { + if (missingBracket) + ParseError.UnclosedBracket.raise[F] + else if (missingSemicolon) + ParseError.MissingSemicolon.raise[F] + else + result.pure[F] +} + +val program: IO[Unit] = Handle.allowF[IO, ParseError] { implicit h => + for { + x <- parse[IO](inputX) + y <- parse[IO](inputY) + _ <- IO.println(s"successfully parsed $x and $y") + } yield () +} rescue { + case ParseError.UnclosedBracket => + IO.println("you didn't close your brackets") + case ParseError.MissingSemicolon => + IO.println("you missed your semicolons very much") + case ParseError.Other(msg) => + IO.println(s"error: $msg") +} +``` + +## Notes on `F[_]` vs `IO` +- `F[_]` is optional; in many codebases `IO` is used directly. +- `Raise[IO, E]` and `Handle[IO, E]` work fine without abstracting over `F[_]`. +- Using `F[_]` makes the same logic testable in `Either[E, *]` or other monads. + +## Behavior and safety +- `allow` creates a lexical scope where `Raise` is available; calling a raising function outside this scope is a compile error. +- `rescue` forces you to handle any raised errors, mirroring try/catch. +- Implementation uses a local traceless Throwable ("submarine error") to transport your domain error within the effect error channel. +- Avoid catching all `Throwable` inside an `allow` scope unless you intentionally want to intercept the submarine error. + +## Test prompts +- Replace an `EitherT[IO, E, A]` flow with Cats MTL `Raise/Handle`. +- Refactor a sealed-on-sealed error hierarchy into composition using wrapper case classes. +- Introduce `Raise[F, E]` for typed errors in a parsing function and `rescue` at the boundary. + +## When to use what +- Use `Raise[F, E]` for domain errors that should be explicit in signatures. +- Use exceptions only when exposing a specific error type would leak implementation details (for example, a database-specific SQLException) or when you are constrained by an inherited OOP interface that cannot express typed errors. +- Use `Either[E, A]` in pure functions and lift into `F` where needed. +- Avoid `EitherT` in effectful code when Cats MTL can express the capability instead. diff --git a/.claude/skills/cats-effect-io b/.claude/skills/cats-effect-io new file mode 120000 index 0000000..9c02e96 --- /dev/null +++ b/.claude/skills/cats-effect-io @@ -0,0 +1 @@ +../../.agents/skills/cats-effect-io \ No newline at end of file diff --git a/.claude/skills/cats-effect-resource b/.claude/skills/cats-effect-resource new file mode 120000 index 0000000..ec04af8 --- /dev/null +++ b/.claude/skills/cats-effect-resource @@ -0,0 +1 @@ +../../.agents/skills/cats-effect-resource \ No newline at end of file diff --git a/.claude/skills/cats-mtl-typed-errors b/.claude/skills/cats-mtl-typed-errors new file mode 120000 index 0000000..873d4f4 --- /dev/null +++ b/.claude/skills/cats-mtl-typed-errors @@ -0,0 +1 @@ +../../.agents/skills/cats-mtl-typed-errors \ No newline at end of file diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 4d24b6e..f26aedd 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -6,7 +6,6 @@ on: tags: - 'v*.*.*' pull_request: - branches: [ main ] jobs: test-gradle: @@ -20,9 +19,9 @@ jobs: matrix: java-version: ['21', '25'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Set up JDK ${{ matrix.java-version }} - uses: actions/setup-java@v4 + uses: actions/setup-java@v5 with: cache: 'gradle' java-version: ${{ matrix.java-version }} @@ -31,16 +30,48 @@ jobs: # ./**/*.gradle.kts # ./gradle/wrapper/gradle-wrapper.properties - name: Setup Gradle - uses: gradle/actions/setup-gradle@v4 + uses: gradle/actions/setup-gradle@v5 - name: Docker Info run: docker info - name: Test run: ./gradlew check --no-daemon - name: Upload Results - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v5 if: always() # This ensures that test results are uploaded even if the test step fails with: name: test-results-${{ matrix.java-version }} path: | **/build/reports/ **/build/test-results/ + + test-sbt: + runs-on: ubuntu-latest + env: + DOCKER_HOST: unix:///var/run/docker.sock + TESTCONTAINERS_HOST_OVERRIDE: localhost + TESTCONTAINERS_RYUK_DISABLED: "true" + strategy: + fail-fast: false + matrix: + java-version: ['21', '25'] + steps: + - uses: actions/checkout@v6 + - name: Set up JDK ${{ matrix.java-version }} + uses: actions/setup-java@v5 + with: + cache: 'sbt' + java-version: ${{ matrix.java-version }} + distribution: 'temurin' + - name: Docker Info + run: docker info + - name: Test (SBT) + run: ./sbt ";publishLocalGradleDependencies;test;scalafmtCheckAll" + - name: Upload Results + uses: actions/upload-artifact@v5 + if: always() + with: + name: test-results-sbt-${{ matrix.java-version }} + path: | + **/target/test-reports/ + **/target/streams/test/ + **/target/scalafmt/ diff --git a/.scalafmt.conf b/.scalafmt.conf index a04acb7..2833a21 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,2 +1,15 @@ version = 3.10.6 -runner.dialect = scala213source3 + +# https://alexn.org/blog/2025/10/26/scala-3-no-indent/ +runner.dialect = scala3 +runner.dialectOverride.allowSignificantIndentation = false +runner.dialectOverride.allowQuietSyntax = true +rewrite.scala3.convertToNewSyntax = true +rewrite.scala3.removeOptionalBraces = false + +maxColumn = 100 +indent.main = 2 +indent.callSite = 2 +indent.extendSite = 2 + +newlines.source = keep diff --git a/build.sbt b/build.sbt index 34b126b..3df2c87 100644 --- a/build.sbt +++ b/build.sbt @@ -1,33 +1,42 @@ import java.io.FileInputStream import java.util.Properties -ThisBuild / scalaVersion := "3.3.7" -ThisBuild / crossScalaVersions := Seq("2.13.18", scalaVersion.value) - -ThisBuild / resolvers ++= Seq(Resolver.mavenLocal) - val publishLocalGradleDependencies = taskKey[Unit]("Builds and publishes gradle dependencies") -val props = settingKey[Properties]("Main project properties") -ThisBuild / props := { - val projectProperties = new Properties() - val rootDir = (ThisBuild / baseDirectory).value - val fis = new FileInputStream(s"$rootDir/gradle.properties") - projectProperties.load(fis) - projectProperties -} +val props = + settingKey[Properties]("Main project properties") -ThisBuild / version := { - val base = props.value.getProperty("project.version") - val isRelease = - sys.env - .get("BUILD_RELEASE") - .filter(_.nonEmpty) - .orElse(Option(System.getProperty("buildRelease"))) - .exists(it => it == "true" || it == "1" || it == "yes" || it == "on") - if (isRelease) base else s"$base-SNAPSHOT" -} +inThisBuild( + Seq( + organization := "org.funfix", + scalaVersion := "3.8.1", + scalacOptions ++= Seq( + "-no-indent" + ), + // --- + // Settings for dealing with the local Gradle-assembled artifacts + // Also see: publishLocalGradleDependencies + resolvers ++= Seq(Resolver.mavenLocal), + props := { + val projectProperties = new Properties() + val rootDir = (ThisBuild / baseDirectory).value + val fis = new FileInputStream(s"$rootDir/gradle.properties") + projectProperties.load(fis) + projectProperties + }, + version := { + val base = props.value.getProperty("project.version") + val isRelease = + sys.env + .get("BUILD_RELEASE") + .filter(_.nonEmpty) + .orElse(Option(System.getProperty("buildRelease"))) + .exists(it => it == "true" || it == "1" || it == "yes" || it == "on") + if (isRelease) base else s"$base-SNAPSHOT" + } + ) +) Global / onChangedBuildSource := ReloadOnSourceChanges @@ -36,6 +45,8 @@ lazy val root = project .settings( publish := {}, publishLocal := {}, + // Task for triggering the Gradle build and publishing the artefacts + // locally, because we depend on them publishLocalGradleDependencies := { import scala.sys.process.* val rootDir = (ThisBuild / baseDirectory).value @@ -60,7 +71,9 @@ lazy val delayedqueue = crossProject(JVMPlatform) ) .jvmSettings( libraryDependencies ++= Seq( - "org.funfix" % "delayedqueue-jvm" % version.value + "org.funfix" % "delayedqueue-jvm" % version.value, + // Testing + "org.scalameta" %% "munit" % "1.0.4" % Test ) ) diff --git a/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/HelloSuite.scala b/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/HelloSuite.scala new file mode 100644 index 0000000..ad31f1a --- /dev/null +++ b/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/HelloSuite.scala @@ -0,0 +1,9 @@ +package org.funfix.delayedqueue.scala + +class HelloSuite extends munit.FunSuite { + test("hello") { + val obtained = 42 + val expected = 42 + assertEquals(obtained, expected) + } +} diff --git a/project/plugins.sbt b/project/plugins.sbt index be8cfee..da89c90 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -4,3 +4,6 @@ addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.20.2") addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.10") addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.6") addSbtPlugin("org.typelevel" % "sbt-tpolecat" % "0.5.2") + +// https://github.com/typelevel/sbt-tpolecat/issues/291 +libraryDependencies += "org.typelevel" %% "scalac-options" % "0.1.9" From b9621366701d80cd21639b353eb6d9a7655db6f7 Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 8 Feb 2026 11:20:46 +0200 Subject: [PATCH 03/12] Enable explicit nulls --- build.sbt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index 3df2c87..7db92e3 100644 --- a/build.sbt +++ b/build.sbt @@ -12,7 +12,8 @@ inThisBuild( organization := "org.funfix", scalaVersion := "3.8.1", scalacOptions ++= Seq( - "-no-indent" + "-no-indent", + "-Yexplicit-nulls", ), // --- // Settings for dealing with the local Gradle-assembled artifacts From d18125ff881db7edcae7e31166c9a6db7c8bee22 Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 8 Feb 2026 11:41:31 +0200 Subject: [PATCH 04/12] Update AGENTS.md for multi-project setup --- AGENTS.md | 87 ++++++++---------------------------- delayedqueue-jvm/AGENTS.md | 68 ++++++++++++++++++++++++++++ delayedqueue-scala/AGENTS.md | 69 ++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 68 deletions(-) create mode 100644 delayedqueue-jvm/AGENTS.md create mode 100644 delayedqueue-scala/AGENTS.md diff --git a/AGENTS.md b/AGENTS.md index 8f0682d..694177f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,80 +1,31 @@ # Agents Guide -This repository ships a Delayed Queue for Java developers, implemented in Kotlin. -All public APIs must look and feel like a Java library. The `kotlin-java-library` -skill is the rule of law for any public surface changes. +This is a multi-project repository containing libraries and tools built for different platforms and languages. -## CRITICAL RULE: FOLLOW THE ORIGINAL IMPLEMENTATION EXACTLY +## Navigation -**When porting from the Scala original in `old-code/`, match the structure EXACTLY.** +**Each project has its own AGENTS.md file with language-specific rules and best practices.** -Do NOT deviate from: -- Configuration class fields and their order -- Method signatures and parameters -- Type names and naming conventions -- Behavior and semantics +Before working on code, identify which project you're in and consult its guide: -The original implementation in `old-code/` is the source of truth. Any deviation must be explicitly justified and documented. +| Project | Path | Description | Guide | +| ------------------------- | --------------------- | ----------------------------------------------- | ----------------------------------------- | +| **Delayed Queue (JVM)** | `delayedqueue-jvm/` | Kotlin implementation targeting Java developers | [AGENTS.md](delayedqueue-jvm/AGENTS.md) | +| **Delayed Queue (Scala)** | `delayedqueue-scala/` | Scala 3 implementation with functional APIs | [AGENTS.md](delayedqueue-scala/AGENTS.md) | -## Non-negotiable rules -- Public API is Java-first: no Kotlin-only surface features or Kotlin stdlib types. -- Keep nullability explicit and stable; avoid platform types in public signatures. -- Do not change or remove published public members; add overloads instead. -- Use JVM interop annotations deliberately to shape Java call sites. -- Verify every public entry point with a Java call-site example. -- Agents MUST practice TDD: write the failing test first, then implement the change. -- Library dependencies should never be added by agents, unless instructed to do so. +## How to Work in This Repository -## Public API constraints (Java consumers) -- Use Java types in signatures: `java.util.List/Map/Set`, `java.time.*`, - `java.util.concurrent.*`, `java.util.Optional`, `java.io.*`. -- Avoid Kotlin types in public API: `kotlin.collections.*`, `kotlin.Result`, - `kotlin.Unit`, `kotlin.Pair/Triple`, `kotlin.time.*`, `kotlinx.*`. -- Avoid default arguments without `@JvmOverloads` or explicit overloads. -- Avoid extension-only entry points; expose top-level or static members. -- Use `@Throws` for checked exceptions expected by Java callers. -- Return defensive copies or unmodifiable views for collections. +1. **Identify the project** you're working in by checking the file path +2. **Read that project's AGENTS.md** for language-specific rules and constraints +3. **Follow the project-specific guidelines** for code style, testing, and API design -## JVM interop patterns -- `@JvmOverloads` for stable default arguments; prefer explicit overloads when - behavior differs between overloads. -- `@JvmStatic` on companion/object functions that should be `static` in Java. -- `@JvmField` only for true constants or immutable fields. -- `@JvmName` to avoid signature clashes or provide stable Java names. -- `@file:JvmName` for top-level utilities, optionally `@file:JvmMultifileClass`. +## Universal Rules (All Projects) -## Binary compatibility -- Declare explicit public return types; avoid inferred public signatures. -- Do not change types, nullability, or parameter order of public members. -- Add new overloads instead of changing existing ones. -- Use a deprecation cycle before removal. - -## Code style / best practices - -- NEVER catch `Throwable`, you're only allowed to catch `Exception` -- Use nice imports instead of fully qualified names -- NEVER use default parameters for database-specific behavior (filters, adapters, etc.) - these MUST match the actual driver/config -- Exception handling must be PRECISE - only catch what you intend to handle. Generic catches like `catch (e: SQLException)` are almost always wrong. - - Use exception filters/matchers for specific error types (DuplicateKey, TransientFailure, etc.) - - Let unexpected exceptions propagate to retry logic - -## Testing - -- Practice TDD: write tests before the implementation. -- Projects strives for full test coverage. Tests have to be clean and easy to read. -- **All tests for public API go into `./src/test/java`, built in Java.** - - If a test calls public methods on `DelayedQueue`, `CronService`, or other public interfaces → Java test - - This ensures the Java API is tested from a Java consumer's perspective -- **All tests for internal implementation go into `./src/test/kotlin`, built in Kotlin.** - - If a test is for internal classes/functions (e.g., `SqlExceptionFilters`, `Raise`, retry logic) → Kotlin test - -## Review checklist -- Java call sites compile for all public constructors and methods. -- No Kotlin stdlib types exposed in public signatures. -- Default args are covered by overloads for Java. -- Exceptions are declared with `@Throws` where relevant. -- Tests targeting the public API should be Java tests. +- **TDD is mandatory**: Write the failing test first, then implement the change. +- **No unauthorized dependencies**: Library dependencies should never be added by agents unless explicitly instructed. +- **Binary compatibility**: Do not change or remove published public members; add overloads instead. +- **Code quality**: Maintain full test coverage; tests must be clean and easy to read. ## References -- Skill: `.claude/skills/kotlin-java-library` -- Reference: `.claude/skills/kotlin-java-library/references/kotlin-java-library.md` + +- Project documentation: `docs/` diff --git a/delayedqueue-jvm/AGENTS.md b/delayedqueue-jvm/AGENTS.md new file mode 100644 index 0000000..bcc74e3 --- /dev/null +++ b/delayedqueue-jvm/AGENTS.md @@ -0,0 +1,68 @@ +# Agents Guide: Delayed Queue for Java + +This is the **Kotlin/Java implementation** of the Delayed Queue for Java developers. +All public APIs must look and feel like a Java library. The `kotlin-java-library` +skill is the rule of law for any public surface changes. + +## Non-negotiable rules +- Public API is Java-first: no Kotlin-only surface features or Kotlin stdlib types. +- Keep nullability explicit and stable; avoid platform types in public signatures. +- Do not change or remove published public members; add overloads instead. +- Use JVM interop annotations deliberately to shape Java call sites. +- Verify every public entry point with a Java call-site example. +- Agents MUST practice TDD: write the failing test first, then implement the change. +- Library dependencies should never be added by agents, unless instructed to do so. + +## Public API constraints (Java consumers) +- Use Java types in signatures: `java.util.List/Map/Set`, `java.time.*`, + `java.util.concurrent.*`, `java.util.Optional`, `java.io.*`. +- Avoid Kotlin types in public API: `kotlin.collections.*`, `kotlin.Result`, + `kotlin.Unit`, `kotlin.Pair/Triple`, `kotlin.time.*`, `kotlinx.*`. +- Avoid default arguments without `@JvmOverloads` or explicit overloads. +- Avoid extension-only entry points; expose top-level or static members. +- Use `@Throws` for checked exceptions expected by Java callers. +- Return defensive copies or unmodifiable views for collections. + +## JVM interop patterns +- `@JvmOverloads` for stable default arguments; prefer explicit overloads when + behavior differs between overloads. +- `@JvmStatic` on companion/object functions that should be `static` in Java. +- `@JvmField` only for true constants or immutable fields. +- `@JvmName` to avoid signature clashes or provide stable Java names. +- `@file:JvmName` for top-level utilities, optionally `@file:JvmMultifileClass`. + +## Binary compatibility +- Declare explicit public return types; avoid inferred public signatures. +- Do not change types, nullability, or parameter order of public members. +- Add new overloads instead of changing existing ones. +- Use a deprecation cycle before removal. + +## Code style / best practices + +- NEVER catch `Throwable`, you're only allowed to catch `Exception` +- Use nice imports instead of fully qualified names +- NEVER use default parameters for database-specific behavior (filters, adapters, etc.) - these MUST match the actual driver/config +- Exception handling must be PRECISE - only catch what you intend to handle. Generic catches like `catch (e: SQLException)` are almost always wrong. + - Use exception filters/matchers for specific error types (DuplicateKey, TransientFailure, etc.) + - Let unexpected exceptions propagate to retry logic + +## Testing + +- Practice TDD: write tests before the implementation. +- Projects strives for full test coverage. Tests have to be clean and easy to read. +- **All tests for public API go into `./src/test/java`, built in Java.** + - If a test calls public methods on `DelayedQueue`, `CronService`, or other public interfaces → Java test + - This ensures the Java API is tested from a Java consumer's perspective +- **All tests for internal implementation go into `./src/test/kotlin`, built in Kotlin.** + - If a test is for internal classes/functions (e.g., `SqlExceptionFilters`, `Raise`, retry logic) → Kotlin test + +## Review checklist +- Java call sites compile for all public constructors and methods. +- No Kotlin stdlib types exposed in public signatures. +- Default args are covered by overloads for Java. +- Exceptions are declared with `@Throws` where relevant. +- Tests targeting the public API should be Java tests. + +## References +- Skill: `.agents/skills/kotlin-java-library` +- Reference: `.agents/skills/kotlin-java-library/references/kotlin-java-library.md` diff --git a/delayedqueue-scala/AGENTS.md b/delayedqueue-scala/AGENTS.md new file mode 100644 index 0000000..0525603 --- /dev/null +++ b/delayedqueue-scala/AGENTS.md @@ -0,0 +1,69 @@ +# Agents Guide: Delayed Queue for Scala + +This is the **Scala implementation** of the Delayed Queue. +The public API should be idiomatic Scala, leveraging Scala 3 features where appropriate. + +## Non-negotiable rules +- Public API is idiomatic Scala: use Scala collections, Option, Either, Try where appropriate. +- Leverage Scala 3 features: extension methods, given/using, top-level definitions, union types. +- Agents MUST practice TDD: write the failing test first, then implement the change. +- Library dependencies should never be added by agents, unless instructed to do so. +- Maintain binary compatibility for published versions. + +## API design principles +- Use immutable data structures by default (List, Vector, Map, Set from scala.collection.immutable). +- Prefer `Option[T]` over nullable references. +- Use `Either[Error, Result]` for error handling in pure functional code. +- Use `Try[T]` for exception-prone operations when wrapping Java APIs. +- Design for composition: small, focused functions/methods. +- Make illegal states unrepresentable with types. + +## Scala 3 features +- Use top-level definitions for utilities instead of objects. +- Prefer `extension` methods over implicit classes. +- Use `given`/`using` for contextual abstractions. +- Leverage union types (`A | B`) and intersection types (`A & B`). +- Use opaque type aliases for type safety without runtime overhead. +- Prefer `enum` for ADTs over sealed traits when appropriate. + +## Code style / best practices +- Use 2-space indentation (Scala standard). +- Prefer val over var; minimize mutable state. +- Use meaningful names; avoid abbreviations unless standard (e.g., `acc` for accumulator). +- Keep functions small and focused; extract helpers when needed. +- Use for-comprehensions for sequential operations with flatMap/map. +- Avoid catching `Throwable`; catch specific exception types. +- Use resource management (Using, AutoCloseable) for IO operations. + +## Testing +- Practice TDD: write tests before the implementation. +- Strive for full test coverage; tests should be clean and readable. +- Use ScalaTest or MUnit (check project setup for which framework is configured). +- Test files mirror source structure: `src/main/scala/X.scala` → `src/test/scala/XSpec.scala`. +- Use property-based testing (ScalaCheck) for algorithmic correctness when appropriate. + +## Database integration +- Exception handling must be PRECISE - only catch what you intend to handle. +- Use typed error handling (Either, Try) over exceptions in business logic. +- Let unexpected exceptions propagate to retry logic. +- Database-specific behavior should be explicit, not hidden in default parameters. + +## Review checklist +- API uses idiomatic Scala types and patterns. +- No leaked implementation details in public signatures. +- Tests cover happy paths and edge cases. +- Code compiles with Scala 3 without warnings. +- Binary compatibility maintained for published versions. + +## Build system +- Project uses `sbt` (see `build.sbt` in root). +- This sub-project has non-standard wiring to the Gradle project, so before anything, we need to ensure that the Gradle dependency was published locally: `sbt publishLocalGradleDependencies` +- Run tests: `sbt test` +- Compile: `sbt compile` +- Format (required): `sbt scalafmtAll` + +## References +- Skills: + - `.agents/skills/cats-effect-io` + - `.agents/skills/cats-effect-resource` + - `.agents/skills/cats-mtl-typed-errors` From 1abe61bfb05bb7f293fd300f76e9d8b18bd903cb Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 8 Feb 2026 11:46:52 +0200 Subject: [PATCH 05/12] Fix --- delayedqueue-scala/AGENTS.md | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/delayedqueue-scala/AGENTS.md b/delayedqueue-scala/AGENTS.md index 0525603..f07b203 100644 --- a/delayedqueue-scala/AGENTS.md +++ b/delayedqueue-scala/AGENTS.md @@ -11,10 +11,9 @@ The public API should be idiomatic Scala, leveraging Scala 3 features where appr - Maintain binary compatibility for published versions. ## API design principles -- Use immutable data structures by default (List, Vector, Map, Set from scala.collection.immutable). -- Prefer `Option[T]` over nullable references. -- Use `Either[Error, Result]` for error handling in pure functional code. -- Use `Try[T]` for exception-prone operations when wrapping Java APIs. +- Use immutable data structures by default (from `scala.collection.immutable`). +- For error handling, use `Either` or `Option` for pure functions, and use Cats-MTL for error handling in effectful functions (returning `IO` or `F[_]`). Avoid `EitherT[IO, E, A]` or `IO[Either[E, A]]`. + - Use skill: `cats-mtl-typed-errors` - Design for composition: small, focused functions/methods. - Make illegal states unrepresentable with types. @@ -32,21 +31,14 @@ The public API should be idiomatic Scala, leveraging Scala 3 features where appr - Use meaningful names; avoid abbreviations unless standard (e.g., `acc` for accumulator). - Keep functions small and focused; extract helpers when needed. - Use for-comprehensions for sequential operations with flatMap/map. -- Avoid catching `Throwable`; catch specific exception types. -- Use resource management (Using, AutoCloseable) for IO operations. +- Avoid catching `Throwable`; catch `NonFatal(e)` and do something meaningful. +- Use resource management via Cats-Effect `Resource` (required), see skill: `cats-effect-resource` ## Testing - Practice TDD: write tests before the implementation. - Strive for full test coverage; tests should be clean and readable. -- Use ScalaTest or MUnit (check project setup for which framework is configured). +- Use MUnit - Test files mirror source structure: `src/main/scala/X.scala` → `src/test/scala/XSpec.scala`. -- Use property-based testing (ScalaCheck) for algorithmic correctness when appropriate. - -## Database integration -- Exception handling must be PRECISE - only catch what you intend to handle. -- Use typed error handling (Either, Try) over exceptions in business logic. -- Let unexpected exceptions propagate to retry logic. -- Database-specific behavior should be explicit, not hidden in default parameters. ## Review checklist - API uses idiomatic Scala types and patterns. From 9024000a94af6530338e105a16857ac33f94e521 Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 8 Feb 2026 12:00:01 +0200 Subject: [PATCH 06/12] Add plan for public data structures --- build.sbt | 5 +- delayedqueue-scala/AGENTS.md | 1 + ...delayedqueue-jvm-public-immutable-types.md | 58 +++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 plans/delayedqueue-jvm-public-immutable-types.md diff --git a/build.sbt b/build.sbt index 7db92e3..ea629b8 100644 --- a/build.sbt +++ b/build.sbt @@ -73,8 +73,11 @@ lazy val delayedqueue = crossProject(JVMPlatform) .jvmSettings( libraryDependencies ++= Seq( "org.funfix" % "delayedqueue-jvm" % version.value, + "org.typelevel" %% "cats-effect" % "3.6.3", + "org.typelevel" %% "cats-mtl" % "1.4.0", // Testing - "org.scalameta" %% "munit" % "1.0.4" % Test + "org.scalameta" %% "munit" % "1.0.4" % Test, + "org.typelevel" %% "munit-cats-effect" % "2.1.0" % Test, ) ) diff --git a/delayedqueue-scala/AGENTS.md b/delayedqueue-scala/AGENTS.md index f07b203..8679814 100644 --- a/delayedqueue-scala/AGENTS.md +++ b/delayedqueue-scala/AGENTS.md @@ -26,6 +26,7 @@ The public API should be idiomatic Scala, leveraging Scala 3 features where appr - Prefer `enum` for ADTs over sealed traits when appropriate. ## Code style / best practices +- Functional programming, pure functions, **required skill**: `cats-effect-io` - Use 2-space indentation (Scala standard). - Prefer val over var; minimize mutable state. - Use meaningful names; avoid abbreviations unless standard (e.g., `acc` for accumulator). diff --git a/plans/delayedqueue-jvm-public-immutable-types.md b/plans/delayedqueue-jvm-public-immutable-types.md new file mode 100644 index 0000000..543eb1c --- /dev/null +++ b/plans/delayedqueue-jvm-public-immutable-types.md @@ -0,0 +1,58 @@ +# Initial Scala data structures + +The following data types from the `delayedqueue-jvm` subproject should be mirrored in Scala. + +Requirements: +- They need to be somewhat adapted to Scala idioms, although there isn't much to do (since the original inspiration was Scala code and these data structures are pretty much compatible with Scala) + - Prefer `Option` for return types, and "nullable" types for input (e.g., `Type | Null`) +- If the original data structures have comments, those comments need to be kept (adapted to Scala and Scaladoc) +- We need back and forth conversions to and from the JVM types, so provide `asJava` and `asScala` extensions +- When side-effects are involved, e.g., the `acknowledge` callback, `IO` must be used. + +## Configs + +- `RetryConfig` — `delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/RetryConfig.kt` + - Exponential-backoff retry policy for DB operations; `data class` with `DEFAULT`/`NO_RETRIES`. +- `JdbcDatabasePoolConfig` — `delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/JdbcDatabasePoolConfig.kt` + - Connection-pool tuning options for Hikari (timeouts, pool sizes); immutable `data class`. +- `DelayedQueueTimeConfig` — `delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/DelayedQueueTimeConfig.kt` + - Acquire/poll timing configuration with several provided defaults. +- `JdbcConnectionConfig` — `delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/JdbcConnectionConfig.kt` + - JDBC connection settings (url, driver, optional credentials and pool). +- `DelayedQueueJDBCConfig` — `delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/DelayedQueueJDBCConfig.kt` + - Composite config for creating JDBC-backed delayed queues (db, table, queueName, time, retryPolicy). + +## Messages & Envelopes + +- `ScheduledMessage` — `delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/ScheduledMessage.kt` + - Message scheduled for future delivery (key, payload, scheduleAt, canUpdate). +- `BatchedMessage` — `delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/ScheduledMessage.kt` + - Input wrapper for batch offering operations. +- `BatchedReply` — `delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/ScheduledMessage.kt` + - Reply for batched offer with `OfferOutcome`. +- `AckEnvelope` — `delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/AckEnvelope.kt` + - Envelope returned from polls with payload, `MessageId`, timestamp, `DeliveryType`, and `acknowledge()`. +- `MessageId` — `delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/AckEnvelope.kt` + - Simple wrapper `data class` for message id string. +- `DeliveryType` — `delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/AckEnvelope.kt` + - Enum: `FIRST_DELIVERY` / `REDELIVERY`. + +## Cron / Scheduling + +- `CronMessage` — `delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/CronMessage.kt` + - Periodic/cron message wrapper; convertible to `ScheduledMessage` via `toScheduled(...)`. +- `CronConfigHash` — `delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/CronConfigHash.kt` + - Hash identifying cron configuration (MD5 string wrapper) used to detect config changes. +- `CronDailySchedule` — `delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/CronDailySchedule.kt` + - Timezone-aware daily schedule config (hours, scheduleInAdvance, scheduleInterval). + +## Return / Outcome / Error types + +- `OfferOutcome` — `delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/OfferOutcome.kt` + - Sealed interface with immutable data objects `Created` / `Updated` / `Ignored` — return of `offer*`. +- `ResourceUnavailableException` — `delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/exceptions.kt` + - Checked exception type (subclass of `Exception`) used when resources (DB) are unavailable. +- `JdbcDriver` — `delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/JdbcDriver.kt` + - Immutable holder for driver `className` and canonical `JvmField` entries for supported drivers. +- `AcknowledgeFun` — `delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/AckEnvelope.kt` + - `fun interface` used as the acknowledge callback inside `AckEnvelope` (functional immutable reference). From 041ee56957243ae8ec0b02ba808409b3c1883b3d Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 8 Feb 2026 12:01:10 +0200 Subject: [PATCH 07/12] Add plan for public data structures --- plans/delayedqueue-jvm-public-immutable-types.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plans/delayedqueue-jvm-public-immutable-types.md b/plans/delayedqueue-jvm-public-immutable-types.md index 543eb1c..280c7b6 100644 --- a/plans/delayedqueue-jvm-public-immutable-types.md +++ b/plans/delayedqueue-jvm-public-immutable-types.md @@ -1,6 +1,6 @@ # Initial Scala data structures -The following data types from the `delayedqueue-jvm` subproject should be mirrored in Scala. +The following data types from the `delayedqueue-jvm` subproject should be mirrored in Scala, in subproject `delayedqueue-scala`. Requirements: - They need to be somewhat adapted to Scala idioms, although there isn't much to do (since the original inspiration was Scala code and these data structures are pretty much compatible with Scala) From 86285cbee240fe30114d0367639c429cd3076f35 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 8 Feb 2026 17:23:34 +0200 Subject: [PATCH 08/12] Add Scala 3 data structures for DelayedQueue with JVM interop (#18) * Initial plan * Add Scala data structures for DelayedQueue - 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> * Add tests for Scala data structures - 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> * Address PR feedback: use Scala types, opaque MessageId, fix implementations - 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 property-based testing with ScalaCheck - 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> * Refactor: delegate implementations to JVM, use idiomatic Scala patterns 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> * Remove default parameters and delegate to JVM defaults - 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> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: alexandru <11753+alexandru@users.noreply.github.com> --- build.sbt | 2 + .../delayedqueue/scala/AckEnvelope.scala | 137 ++++++++++++++ .../delayedqueue/scala/CronConfigHash.scala | 64 +++++++ .../scala/CronDailySchedule.scala | 100 +++++++++++ .../delayedqueue/scala/CronMessage.scala | 118 ++++++++++++ .../scala/DelayedQueueJDBCConfig.scala | 126 +++++++++++++ .../scala/DelayedQueueTimeConfig.scala | 74 ++++++++ .../scala/JdbcConnectionConfig.scala | 68 +++++++ .../scala/JdbcDatabasePoolConfig.scala | 88 +++++++++ .../delayedqueue/scala/JdbcDriver.scala | 78 ++++++++ .../delayedqueue/scala/OfferOutcome.scala | 60 +++++++ .../delayedqueue/scala/RetryConfig.scala | 117 ++++++++++++ .../delayedqueue/scala/ScheduledMessage.scala | 142 +++++++++++++++ .../delayedqueue/scala/exceptions.scala | 28 +++ .../funfix/delayedqueue/scala/CronSpec.scala | 122 +++++++++++++ .../scala/DataStructuresPropertySpec.scala | 170 ++++++++++++++++++ .../delayedqueue/scala/Generators.scala | 44 +++++ .../delayedqueue/scala/JdbcDriverSpec.scala | 55 ++++++ .../delayedqueue/scala/RetryConfigSpec.scala | 78 ++++++++ .../scala/ScheduledMessageSpec.scala | 70 ++++++++ 20 files changed, 1741 insertions(+) create mode 100644 delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/AckEnvelope.scala create mode 100644 delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronConfigHash.scala create mode 100644 delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronDailySchedule.scala create mode 100644 delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronMessage.scala create mode 100644 delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueJDBCConfig.scala create mode 100644 delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueTimeConfig.scala create mode 100644 delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/JdbcConnectionConfig.scala create mode 100644 delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/JdbcDatabasePoolConfig.scala create mode 100644 delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/JdbcDriver.scala create mode 100644 delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/OfferOutcome.scala create mode 100644 delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/RetryConfig.scala create mode 100644 delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/ScheduledMessage.scala create mode 100644 delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/exceptions.scala create mode 100644 delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/CronSpec.scala create mode 100644 delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/DataStructuresPropertySpec.scala create mode 100644 delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/Generators.scala create mode 100644 delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/JdbcDriverSpec.scala create mode 100644 delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/RetryConfigSpec.scala create mode 100644 delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/ScheduledMessageSpec.scala diff --git a/build.sbt b/build.sbt index ea629b8..323d9fc 100644 --- a/build.sbt +++ b/build.sbt @@ -78,6 +78,8 @@ lazy val delayedqueue = crossProject(JVMPlatform) // Testing "org.scalameta" %% "munit" % "1.0.4" % Test, "org.typelevel" %% "munit-cats-effect" % "2.1.0" % Test, + "org.scalacheck" %% "scalacheck" % "1.19.0" % Test, + "org.scalameta" %% "munit-scalacheck" % "1.2.0" % Test, ) ) diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/AckEnvelope.scala b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/AckEnvelope.scala new file mode 100644 index 0000000..80ebc8a --- /dev/null +++ b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/AckEnvelope.scala @@ -0,0 +1,137 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import cats.effect.IO +import java.time.Instant +import org.funfix.delayedqueue.jvm + +/** Message envelope that includes an acknowledgment callback. + * + * This wrapper is returned when polling messages from the queue. It contains the message payload + * plus metadata and an acknowledgment function that should be called after processing completes. + * + * This type is not serializable. + * + * ==Example== + * + * {{{ + * for { + * envelope <- queue.poll + * _ <- processMessage(envelope.payload) + * _ <- envelope.acknowledge + * } yield () + * }}} + * + * @tparam A + * the type of the message payload + * @param payload + * the actual message content + * @param messageId + * unique identifier for tracking this message + * @param timestamp + * when this envelope was created (poll time) + * @param source + * identifier for the queue or source system + * @param deliveryType + * indicates whether this is the first delivery or a redelivery + * @param acknowledge + * IO action to call to acknowledge successful processing, and delete the message from the queue + */ +final case class AckEnvelope[+A]( + payload: A, + messageId: MessageId, + timestamp: Instant, + source: String, + deliveryType: DeliveryType, + acknowledge: IO[Unit] +) + +object AckEnvelope { + + /** Conversion extension for JVM AckEnvelope. */ + extension [A](javaEnv: jvm.AckEnvelope[A]) { + + /** Converts a JVM AckEnvelope to a Scala AckEnvelope. */ + def asScala: AckEnvelope[A] = + AckEnvelope( + payload = javaEnv.payload, + messageId = MessageId.asScala(javaEnv.messageId), + timestamp = javaEnv.timestamp, + source = javaEnv.source, + deliveryType = DeliveryType.asScala(javaEnv.deliveryType), + acknowledge = IO.blocking(javaEnv.acknowledge()) + ) + } +} + +/** Unique identifier for a message. */ +opaque type MessageId = String + +object MessageId { + + /** Creates a MessageId from a String value. */ + def apply(value: String): MessageId = value + + /** Conversion extension for String to MessageId. */ + extension (id: MessageId) { + + /** Gets the string value of the MessageId. */ + def value: String = id + + /** Converts this Scala MessageId to a JVM MessageId. */ + def asJava: jvm.MessageId = + new jvm.MessageId(id) + } + + /** Conversion extension for JVM MessageId. */ + extension (javaId: jvm.MessageId) { + + /** Converts a JVM MessageId to a Scala MessageId. */ + def asScala: MessageId = + MessageId(javaId.value) + } +} + +/** Indicates whether a message is being delivered for the first time or redelivered. */ +enum DeliveryType { + + /** Message is being delivered for the first time. */ + case FirstDelivery + + /** Message is being redelivered (was scheduled again after initial delivery). */ + case Redelivery + + /** Converts this Scala DeliveryType to a JVM DeliveryType. */ + def asJava: jvm.DeliveryType = this match { + case FirstDelivery => jvm.DeliveryType.FIRST_DELIVERY + case Redelivery => jvm.DeliveryType.REDELIVERY + } +} + +object DeliveryType { + + /** Conversion extension for JVM DeliveryType. */ + extension (javaType: jvm.DeliveryType) { + + /** Converts a JVM DeliveryType to a Scala DeliveryType. */ + def asScala: DeliveryType = javaType match { + case jvm.DeliveryType.FIRST_DELIVERY => DeliveryType.FirstDelivery + case jvm.DeliveryType.REDELIVERY => DeliveryType.Redelivery + } + } +} diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronConfigHash.scala b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronConfigHash.scala new file mode 100644 index 0000000..44957a8 --- /dev/null +++ b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronConfigHash.scala @@ -0,0 +1,64 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import org.funfix.delayedqueue.jvm + +/** Hash of a cron configuration, used to detect configuration changes. + * + * When a cron schedule is installed, this hash is used to identify messages belonging to that + * configuration. If the configuration changes, the hash will differ, allowing the system to clean + * up old scheduled messages. + */ +opaque type CronConfigHash = String + +object CronConfigHash { + + /** Creates a CronConfigHash from a String value. */ + def apply(value: String): CronConfigHash = value + + /** Conversion extension for CronConfigHash. */ + extension (hash: CronConfigHash) { + + /** Gets the string value of the CronConfigHash. */ + def value: String = hash + + /** Converts this Scala CronConfigHash to a JVM CronConfigHash. */ + def asJava: jvm.CronConfigHash = + new jvm.CronConfigHash(hash) + } + + /** Creates a ConfigHash from a daily cron schedule configuration. */ + def fromDailyCron(config: CronDailySchedule): CronConfigHash = + jvm.CronConfigHash.fromDailyCron(config.asJava).asScala + + /** Creates a ConfigHash from a periodic tick configuration. */ + def fromPeriodicTick(period: java.time.Duration): CronConfigHash = + jvm.CronConfigHash.fromPeriodicTick(period).asScala + + /** Creates a ConfigHash from an arbitrary string. */ + def fromString(text: String): CronConfigHash = + jvm.CronConfigHash.fromString(text).asScala + + /** Conversion extension for JVM CronConfigHash. */ + extension (javaHash: jvm.CronConfigHash) { + + /** Converts a JVM CronConfigHash to a Scala CronConfigHash. */ + def asScala: CronConfigHash = + CronConfigHash(javaHash.value) + } +} diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronDailySchedule.scala b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronDailySchedule.scala new file mode 100644 index 0000000..4849198 --- /dev/null +++ b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronDailySchedule.scala @@ -0,0 +1,100 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import java.time.Duration +import java.time.Instant +import java.time.LocalTime +import java.time.ZoneId +import org.funfix.delayedqueue.jvm +import scala.jdk.CollectionConverters.* + +/** Configuration for daily recurring scheduled messages with timezone support. + * + * This class defines when messages should be scheduled each day, with support for multiple times + * per day and scheduling messages in advance. + * + * @param zoneId + * the timezone for interpreting the hours of day + * @param hoursOfDay + * the times during each day when messages should be scheduled (must not be empty) + * @param scheduleInAdvance + * how far in advance to schedule messages + * @param scheduleInterval + * how often to check and update the schedule + */ +final case class CronDailySchedule( + zoneId: ZoneId, + hoursOfDay: List[LocalTime], + scheduleInAdvance: Duration, + scheduleInterval: Duration +) { + require(hoursOfDay.nonEmpty, "hoursOfDay must not be empty") + require( + !scheduleInterval.isZero && !scheduleInterval.isNegative, + "scheduleInterval must be positive" + ) + + /** Calculates the next scheduled times starting from now. + * + * Returns all times that should be scheduled, from now until (now + scheduleInAdvance). Always + * returns at least one time (the next scheduled time), even if it's beyond scheduleInAdvance. + * + * @param now + * the current time + * @return + * list of future instants when messages should be scheduled (never empty) + */ + def getNextTimes(now: Instant): List[Instant] = { + import scala.jdk.CollectionConverters.* + asJava.getNextTimes(now).asScala.toList + } + + /** Converts this Scala CronDailySchedule to a JVM CronDailySchedule. */ + def asJava: jvm.CronDailySchedule = + new jvm.CronDailySchedule( + zoneId, + hoursOfDay.asJava, + scheduleInAdvance, + scheduleInterval + ) +} + +object CronDailySchedule { + + /** Creates a DailyCronSchedule with the specified configuration. */ + def create( + zoneId: ZoneId, + hoursOfDay: List[LocalTime], + scheduleInAdvance: Duration, + scheduleInterval: Duration + ): CronDailySchedule = + CronDailySchedule(zoneId, hoursOfDay, scheduleInAdvance, scheduleInterval) + + /** Conversion extension for JVM CronDailySchedule. */ + extension (javaSchedule: jvm.CronDailySchedule) { + + /** Converts a JVM CronDailySchedule to a Scala CronDailySchedule. */ + def asScala: CronDailySchedule = + CronDailySchedule( + zoneId = javaSchedule.zoneId, + hoursOfDay = javaSchedule.hoursOfDay.asScala.toList, + scheduleInAdvance = javaSchedule.scheduleInAdvance, + scheduleInterval = javaSchedule.scheduleInterval + ) + } +} diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronMessage.scala b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronMessage.scala new file mode 100644 index 0000000..987964e --- /dev/null +++ b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronMessage.scala @@ -0,0 +1,118 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import java.time.Instant +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.Locale +import org.funfix.delayedqueue.jvm + +/** Represents a message for periodic (cron-like) scheduling. + * + * This wrapper is used for messages that should be scheduled repeatedly. The `scheduleAt` is used + * to generate the unique key, while `scheduleAtActual` allows for a different execution time + * (e.g., to add a delay). + * + * @tparam A + * the type of the message payload + * @param payload + * the message content + * @param scheduleAt + * the nominal schedule time (used for key generation) + * @param scheduleAtActual + * the actual execution time (defaults to scheduleAt if None) + */ +final case class CronMessage[+A]( + payload: A, + scheduleAt: Instant, + scheduleAtActual: Option[Instant] = None +) { + + /** Converts this CronMessage to a ScheduledMessage. + * + * @param configHash + * the configuration hash for this cron job + * @param keyPrefix + * the prefix for generating unique keys + * @param canUpdate + * whether the resulting message can update existing entries + */ + def toScheduled( + configHash: CronConfigHash, + keyPrefix: String, + canUpdate: Boolean + ): ScheduledMessage[A] = + ScheduledMessage( + key = CronMessage.key(configHash, keyPrefix, scheduleAt), + payload = payload, + scheduleAt = scheduleAtActual.getOrElse(scheduleAt), + canUpdate = canUpdate + ) + + /** Converts this Scala CronMessage to a JVM CronMessage. */ + def asJava[A1 >: A]: jvm.CronMessage[A1] = + new jvm.CronMessage[A1](payload, scheduleAt, scheduleAtActual.getOrElse(null)) +} + +object CronMessage { + + /** Generates a unique key for a cron message. + * + * @param configHash + * the configuration hash + * @param keyPrefix + * the key prefix + * @param scheduleAt + * the schedule time + * @return + * a unique key string + */ + def key(configHash: CronConfigHash, keyPrefix: String, scheduleAt: Instant): String = + jvm.CronMessage.key(configHash.asJava, keyPrefix, scheduleAt) + + /** Creates a factory function that produces CronMessages with a static payload. + * + * @param payload + * the static payload to use for all generated messages + * @return + * a function that creates CronMessages for any given instant + */ + def staticPayload[A](payload: A): CronMessageGenerator[A] = { + val jvmGenerator = jvm.CronMessage.staticPayload(payload) + (scheduleAt: Instant) => jvmGenerator.invoke(scheduleAt).asScala + } + + /** Conversion extension for JVM CronMessage. */ + extension [A](javaMsg: jvm.CronMessage[A]) { + + /** Converts a JVM CronMessage to a Scala CronMessage. */ + def asScala: CronMessage[A] = + CronMessage( + payload = javaMsg.payload, + scheduleAt = javaMsg.scheduleAt, + scheduleAtActual = Option(javaMsg.scheduleAtActual) + ) + } +} + +/** Function that generates CronMessages for given instants. */ +trait CronMessageGenerator[A] { + + /** Creates a cron message for the given instant. */ + def apply(at: Instant): CronMessage[A] +} diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueJDBCConfig.scala b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueJDBCConfig.scala new file mode 100644 index 0000000..8c64d53 --- /dev/null +++ b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueJDBCConfig.scala @@ -0,0 +1,126 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import org.funfix.delayedqueue.jvm + +/** Configuration for JDBC-based delayed queue instances. + * + * This configuration groups together all settings needed to create a DelayedQueueJDBC instance. + * + * ==Example== + * + * {{{ + * val dbConfig = JdbcConnectionConfig( + * url = "jdbc:hsqldb:mem:testdb", + * driver = JdbcDriver.HSQLDB, + * username = "SA", + * password = "", + * pool = null + * ) + * + * val config = DelayedQueueJDBCConfig( + * db = dbConfig, + * tableName = "delayed_queue_table", + * time = DelayedQueueTimeConfig.DEFAULT_JDBC, + * queueName = "my-queue", + * ackEnvSource = "DelayedQueueJDBC:my-queue", + * retryPolicy = Some(RetryConfig.DEFAULT) + * ) + * }}} + * + * @param db + * JDBC connection configuration + * @param tableName + * Name of the database table to use for storing queue messages + * @param time + * Time configuration for queue operations (poll periods, timeouts, etc.) + * @param queueName + * Unique name for this queue instance, used for partitioning messages in shared tables. Multiple + * queue instances can share the same database table if they have different queue names. + * @param ackEnvSource + * Source identifier for acknowledgement envelopes, used for tracing and debugging. Typically, + * follows the pattern "DelayedQueueJDBC:{queueName}". + * @param retryPolicy + * Optional retry configuration for database operations. If None, uses RetryConfig.DEFAULT. + */ +final case class DelayedQueueJDBCConfig( + db: JdbcConnectionConfig, + tableName: String, + time: DelayedQueueTimeConfig, + queueName: String, + ackEnvSource: String, + retryPolicy: Option[RetryConfig] +) { + require(tableName.nonEmpty, "tableName must not be blank") + require(queueName.nonEmpty, "queueName must not be blank") + require(ackEnvSource.nonEmpty, "ackEnvSource must not be blank") + + /** Converts this Scala DelayedQueueJDBCConfig to a JVM DelayedQueueJDBCConfig. */ + def asJava: jvm.DelayedQueueJDBCConfig = + new jvm.DelayedQueueJDBCConfig( + db.asJava, + tableName, + time.asJava, + queueName, + ackEnvSource, + retryPolicy.map(_.asJava).getOrElse(null) + ) +} + +object DelayedQueueJDBCConfig { + + /** Creates a default configuration for the given database, table name, and queue name. + * + * @param db + * JDBC connection configuration + * @param tableName + * Name of the database table to use + * @param queueName + * Unique name for this queue instance + * @return + * A configuration with default time and retry policies + */ + def create( + db: JdbcConnectionConfig, + tableName: String, + queueName: String + ): DelayedQueueJDBCConfig = + DelayedQueueJDBCConfig( + db = db, + tableName = tableName, + time = DelayedQueueTimeConfig.DEFAULT_JDBC, + queueName = queueName, + ackEnvSource = s"DelayedQueueJDBC:$queueName", + retryPolicy = Some(RetryConfig.DEFAULT) + ) + + /** Conversion extension for JVM DelayedQueueJDBCConfig. */ + extension (javaConfig: jvm.DelayedQueueJDBCConfig) { + + /** Converts a JVM DelayedQueueJDBCConfig to a Scala DelayedQueueJDBCConfig. */ + def asScala: DelayedQueueJDBCConfig = + DelayedQueueJDBCConfig( + db = JdbcConnectionConfig.asScala(javaConfig.db), + tableName = javaConfig.tableName, + time = DelayedQueueTimeConfig.asScala(javaConfig.time), + queueName = javaConfig.queueName, + ackEnvSource = javaConfig.ackEnvSource, + retryPolicy = Option(javaConfig.retryPolicy).map(RetryConfig.asScala) + ) + } +} diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueTimeConfig.scala b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueTimeConfig.scala new file mode 100644 index 0000000..bb34909 --- /dev/null +++ b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueTimeConfig.scala @@ -0,0 +1,74 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import scala.concurrent.duration.FiniteDuration +import org.funfix.delayedqueue.jvm + +/** Time configuration for delayed queue operations. + * + * @param acquireTimeout + * maximum time to wait when acquiring/locking a message for processing + * @param pollPeriod + * interval between poll attempts when no messages are available + */ +final case class DelayedQueueTimeConfig( + acquireTimeout: FiniteDuration, + pollPeriod: FiniteDuration +) { + + /** Converts this Scala DelayedQueueTimeConfig to a JVM DelayedQueueTimeConfig. */ + def asJava: jvm.DelayedQueueTimeConfig = + new jvm.DelayedQueueTimeConfig( + java.time.Duration.ofMillis(acquireTimeout.toMillis), + java.time.Duration.ofMillis(pollPeriod.toMillis) + ) +} + +object DelayedQueueTimeConfig { + + /** Default configuration for DelayedQueueInMemory. */ + val DEFAULT_IN_MEMORY: DelayedQueueTimeConfig = + jvm.DelayedQueueTimeConfig.DEFAULT_IN_MEMORY.asScala + + /** Default configuration for JDBC-based implementations, with longer acquire timeouts and poll + * periods to reduce database load in production environments. + */ + val DEFAULT_JDBC: DelayedQueueTimeConfig = + jvm.DelayedQueueTimeConfig.DEFAULT_JDBC.asScala + + /** Default configuration for testing, with shorter timeouts and poll periods to speed up tests. + */ + val DEFAULT_TESTING: DelayedQueueTimeConfig = + jvm.DelayedQueueTimeConfig.DEFAULT_TESTING.asScala + + /** Conversion extension for JVM DelayedQueueTimeConfig. */ + extension (javaConfig: jvm.DelayedQueueTimeConfig) { + + /** Converts a JVM DelayedQueueTimeConfig to a Scala DelayedQueueTimeConfig. */ + def asScala: DelayedQueueTimeConfig = + DelayedQueueTimeConfig( + acquireTimeout = + FiniteDuration( + javaConfig.acquireTimeout.toMillis, + scala.concurrent.duration.MILLISECONDS + ), + pollPeriod = + FiniteDuration(javaConfig.pollPeriod.toMillis, scala.concurrent.duration.MILLISECONDS) + ) + } +} diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/JdbcConnectionConfig.scala b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/JdbcConnectionConfig.scala new file mode 100644 index 0000000..817c081 --- /dev/null +++ b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/JdbcConnectionConfig.scala @@ -0,0 +1,68 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import org.funfix.delayedqueue.jvm + +/** Represents the configuration for a JDBC connection. + * + * @param url + * the JDBC connection URL + * @param driver + * the JDBC driver to use + * @param username + * optional username for authentication + * @param password + * optional password for authentication + * @param pool + * optional connection pool configuration + */ +final case class JdbcConnectionConfig( + url: String, + driver: JdbcDriver, + username: Option[String] = None, + password: Option[String] = None, + pool: Option[JdbcDatabasePoolConfig] = None +) { + + /** Converts this Scala JdbcConnectionConfig to a JVM JdbcConnectionConfig. */ + def asJava: jvm.JdbcConnectionConfig = + new jvm.JdbcConnectionConfig( + url, + driver.asJava, + username.getOrElse(null), + password.getOrElse(null), + pool.map(_.asJava).getOrElse(null) + ) +} + +object JdbcConnectionConfig { + + /** Conversion extension for JVM JdbcConnectionConfig. */ + extension (javaConfig: jvm.JdbcConnectionConfig) { + + /** Converts a JVM JdbcConnectionConfig to a Scala JdbcConnectionConfig. */ + def asScala: JdbcConnectionConfig = + JdbcConnectionConfig( + url = javaConfig.url, + driver = JdbcDriver.asScala(javaConfig.driver), + username = Option(javaConfig.username), + password = Option(javaConfig.password), + pool = Option(javaConfig.pool).map(JdbcDatabasePoolConfig.asScala) + ) + } +} diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/JdbcDatabasePoolConfig.scala b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/JdbcDatabasePoolConfig.scala new file mode 100644 index 0000000..1ac2a33 --- /dev/null +++ b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/JdbcDatabasePoolConfig.scala @@ -0,0 +1,88 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import java.time.Duration +import org.funfix.delayedqueue.jvm + +/** Configuration for tuning the Hikari connection pool. + * + * @param connectionTimeout + * maximum time to wait for a connection from the pool + * @param idleTimeout + * maximum time a connection can sit idle in the pool + * @param maxLifetime + * maximum lifetime of a connection in the pool + * @param keepaliveTime + * frequency of keepalive checks + * @param maximumPoolSize + * maximum number of connections in the pool + * @param minimumIdle + * minimum number of idle connections to maintain + * @param leakDetectionThreshold + * time before a connection is considered leaked + * @param initializationFailTimeout + * time to wait for pool initialization + */ +final case class JdbcDatabasePoolConfig( + connectionTimeout: Duration, + idleTimeout: Duration, + maxLifetime: Duration, + keepaliveTime: Duration, + maximumPoolSize: Int, + minimumIdle: Option[Int], + leakDetectionThreshold: Option[Duration], + initializationFailTimeout: Option[Duration] +) { + + /** Converts this Scala JdbcDatabasePoolConfig to a JVM JdbcDatabasePoolConfig. */ + def asJava: jvm.JdbcDatabasePoolConfig = + new jvm.JdbcDatabasePoolConfig( + connectionTimeout, + idleTimeout, + maxLifetime, + keepaliveTime, + maximumPoolSize, + minimumIdle.map(Int.box).getOrElse(null), + leakDetectionThreshold.getOrElse(null), + initializationFailTimeout.getOrElse(null) + ) +} + +object JdbcDatabasePoolConfig { + + /** Default connection pool configuration. */ + val DEFAULT: JdbcDatabasePoolConfig = + new jvm.JdbcDatabasePoolConfig().asScala + + /** Conversion extension for JVM JdbcDatabasePoolConfig. */ + extension (javaConfig: jvm.JdbcDatabasePoolConfig) { + + /** Converts a JVM JdbcDatabasePoolConfig to a Scala JdbcDatabasePoolConfig. */ + def asScala: JdbcDatabasePoolConfig = + JdbcDatabasePoolConfig( + connectionTimeout = javaConfig.connectionTimeout, + idleTimeout = javaConfig.idleTimeout, + maxLifetime = javaConfig.maxLifetime, + keepaliveTime = javaConfig.keepaliveTime, + maximumPoolSize = javaConfig.maximumPoolSize, + minimumIdle = Option(javaConfig.minimumIdle).map(_.intValue), + leakDetectionThreshold = Option(javaConfig.leakDetectionThreshold), + initializationFailTimeout = Option(javaConfig.initializationFailTimeout) + ) + } +} diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/JdbcDriver.scala b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/JdbcDriver.scala new file mode 100644 index 0000000..3fb5bb0 --- /dev/null +++ b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/JdbcDriver.scala @@ -0,0 +1,78 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import org.funfix.delayedqueue.jvm + +/** JDBC driver configurations. + * + * @param className + * the JDBC driver class name + */ +final case class JdbcDriver private (className: String) { + + /** Converts this Scala JdbcDriver to a JVM JdbcDriver. */ + def asJava: jvm.JdbcDriver = + JdbcDriver.jvmEntries.getOrElse( + this, + throw new IllegalArgumentException(s"Unknown JDBC driver: $className") + ) +} + +object JdbcDriver { + + val HSQLDB: JdbcDriver = jvm.JdbcDriver.HSQLDB.asScala + val H2: JdbcDriver = jvm.JdbcDriver.H2.asScala + val MsSqlServer: JdbcDriver = jvm.JdbcDriver.MsSqlServer.asScala + val Sqlite: JdbcDriver = jvm.JdbcDriver.Sqlite.asScala + val MariaDB: JdbcDriver = jvm.JdbcDriver.MariaDB.asScala + val MySQL: JdbcDriver = jvm.JdbcDriver.MySQL.asScala + val PostgreSQL: JdbcDriver = jvm.JdbcDriver.PostgreSQL.asScala + val Oracle: JdbcDriver = jvm.JdbcDriver.Oracle.asScala + + val entries: List[JdbcDriver] = + List(H2, HSQLDB, MariaDB, MsSqlServer, MySQL, PostgreSQL, Sqlite, Oracle) + + private val jvmEntries: Map[JdbcDriver, jvm.JdbcDriver] = Map( + H2 -> jvm.JdbcDriver.H2, + HSQLDB -> jvm.JdbcDriver.HSQLDB, + MariaDB -> jvm.JdbcDriver.MariaDB, + MsSqlServer -> jvm.JdbcDriver.MsSqlServer, + MySQL -> jvm.JdbcDriver.MySQL, + PostgreSQL -> jvm.JdbcDriver.PostgreSQL, + Sqlite -> jvm.JdbcDriver.Sqlite, + Oracle -> jvm.JdbcDriver.Oracle + ) + + /** Attempt to find a JdbcDriver by its class name. + * + * @param className + * the JDBC driver class name + * @return + * the JdbcDriver if found, None otherwise + */ + def fromClassName(className: String): Option[JdbcDriver] = + entries.find(_.className.equalsIgnoreCase(className)) + + /** Conversion extension for JVM JdbcDriver. */ + extension (javaDriver: jvm.JdbcDriver) { + + /** Converts a JVM JdbcDriver to a Scala JdbcDriver. */ + def asScala: JdbcDriver = + JdbcDriver(javaDriver.getClassName()) + } +} diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/OfferOutcome.scala b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/OfferOutcome.scala new file mode 100644 index 0000000..4f34a92 --- /dev/null +++ b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/OfferOutcome.scala @@ -0,0 +1,60 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import org.funfix.delayedqueue.jvm + +/** Outcome of offering a message to the delayed queue. + * + * This sealed trait represents the possible results when adding or updating a message in the + * queue. + */ +sealed trait OfferOutcome { + + /** Returns true if the offer was ignored (message already exists and cannot be updated). */ + def isIgnored: Boolean = this == OfferOutcome.Ignored + + /** Converts this Scala OfferOutcome to a JVM OfferOutcome. */ + def asJava: jvm.OfferOutcome = this match { + case OfferOutcome.Created => jvm.OfferOutcome.Created.INSTANCE + case OfferOutcome.Updated => jvm.OfferOutcome.Updated.INSTANCE + case OfferOutcome.Ignored => jvm.OfferOutcome.Ignored.INSTANCE + } +} + +object OfferOutcome { + + /** Message was successfully created (new entry). */ + case object Created extends OfferOutcome + + /** Message was successfully updated (existing entry modified). */ + case object Updated extends OfferOutcome + + /** Message offer was ignored (already exists and canUpdate was false). */ + case object Ignored extends OfferOutcome + + /** Conversion extension for JVM OfferOutcome. */ + extension (javaOutcome: jvm.OfferOutcome) { + + /** Converts a JVM OfferOutcome to a Scala OfferOutcome. */ + def asScala: OfferOutcome = javaOutcome match { + case _: jvm.OfferOutcome.Created => OfferOutcome.Created + case _: jvm.OfferOutcome.Updated => OfferOutcome.Updated + case _: jvm.OfferOutcome.Ignored => OfferOutcome.Ignored + } + } +} diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/RetryConfig.scala b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/RetryConfig.scala new file mode 100644 index 0000000..e967bd9 --- /dev/null +++ b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/RetryConfig.scala @@ -0,0 +1,117 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import java.time.Duration +import org.funfix.delayedqueue.jvm + +/** Configuration for retry loops with exponential backoff. + * + * Used to configure retry behavior for database operations that may experience transient failures + * such as deadlocks, connection issues, or transaction rollbacks. + * + * ==Example== + * + * {{{ + * val config = RetryConfig( + * maxRetries = Some(3), + * totalSoftTimeout = Some(Duration.ofSeconds(30)), + * perTryHardTimeout = Some(Duration.ofSeconds(10)), + * initialDelay = Duration.ofMillis(100), + * maxDelay = Duration.ofSeconds(5), + * backoffFactor = 2.0 + * ) + * }}} + * + * @param initialDelay + * Initial delay before first retry + * @param maxDelay + * Maximum delay between retries (backoff is capped at this value) + * @param backoffFactor + * Multiplier for exponential backoff (e.g., 2.0 for doubling delays) + * @param maxRetries + * Maximum number of retries (None means unlimited retries) + * @param totalSoftTimeout + * Total time after which retries stop (None means no timeout) + * @param perTryHardTimeout + * Hard timeout for each individual attempt (None means no per-try timeout) + */ +final case class RetryConfig( + initialDelay: Duration, + maxDelay: Duration, + backoffFactor: Double, + maxRetries: Option[Long], + totalSoftTimeout: Option[Duration], + perTryHardTimeout: Option[Duration] +) { + require(backoffFactor >= 1.0, s"backoffFactor must be >= 1.0, got $backoffFactor") + require(!initialDelay.isNegative, s"initialDelay must not be negative, got $initialDelay") + require(!maxDelay.isNegative, s"maxDelay must not be negative, got $maxDelay") + require(maxRetries.forall(_ >= 0), s"maxRetries must be >= 0 or None, got $maxRetries") + require( + totalSoftTimeout.forall(!_.isNegative), + s"totalSoftTimeout must not be negative, got $totalSoftTimeout" + ) + require( + perTryHardTimeout.forall(!_.isNegative), + s"perTryHardTimeout must not be negative, got $perTryHardTimeout" + ) + + /** Converts this Scala RetryConfig to a JVM RetryConfig. */ + def asJava: jvm.RetryConfig = + new jvm.RetryConfig( + initialDelay, + maxDelay, + backoffFactor, + maxRetries.map(Long.box).getOrElse(null), + totalSoftTimeout.getOrElse(null), + perTryHardTimeout.getOrElse(null) + ) +} + +object RetryConfig { + + /** Default retry configuration with reasonable defaults for database operations: + * - 5 retries maximum + * - 30 second total timeout + * - 10 second per-try timeout + * - 100ms initial delay + * - 5 second max delay + * - 2.0 backoff factor (exponential doubling) + */ + val DEFAULT: RetryConfig = + jvm.RetryConfig.DEFAULT.asScala + + /** No retries - operations fail immediately on first error. */ + val NO_RETRIES: RetryConfig = + jvm.RetryConfig.NO_RETRIES.asScala + + /** Conversion extension for JVM RetryConfig. */ + extension (javaConfig: jvm.RetryConfig) { + + /** Converts a JVM RetryConfig to a Scala RetryConfig. */ + def asScala: RetryConfig = + RetryConfig( + initialDelay = javaConfig.initialDelay, + maxDelay = javaConfig.maxDelay, + backoffFactor = javaConfig.backoffFactor, + maxRetries = Option(javaConfig.maxRetries).map(_.longValue), + totalSoftTimeout = Option(javaConfig.totalSoftTimeout), + perTryHardTimeout = Option(javaConfig.perTryHardTimeout) + ) + } +} diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/ScheduledMessage.scala b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/ScheduledMessage.scala new file mode 100644 index 0000000..2679f04 --- /dev/null +++ b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/ScheduledMessage.scala @@ -0,0 +1,142 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import java.time.Instant +import org.funfix.delayedqueue.jvm + +/** Represents a message scheduled for future delivery in the delayed queue. + * + * This is the primary data structure for messages that will be processed at a specific time in the + * future. + * + * @tparam A + * the type of the message payload + * @param key + * unique identifier for this message; can be used to update or delete the message + * @param payload + * the actual message content + * @param scheduleAt + * the timestamp when this message becomes available for polling + * @param canUpdate + * whether existing messages with the same key can be updated + */ +final case class ScheduledMessage[+A]( + key: String, + payload: A, + scheduleAt: Instant, + canUpdate: Boolean = true +) { + + /** Converts this Scala ScheduledMessage to a JVM ScheduledMessage. */ + def asJava[A1 >: A]: jvm.ScheduledMessage[A1] = + new jvm.ScheduledMessage[A1](key, payload, scheduleAt, canUpdate) +} + +object ScheduledMessage { + + /** Conversion extension for JVM ScheduledMessage. */ + extension [A](javaMsg: jvm.ScheduledMessage[A]) { + + /** Converts a JVM ScheduledMessage to a Scala ScheduledMessage. */ + def asScala: ScheduledMessage[A] = + ScheduledMessage( + key = javaMsg.key, + payload = javaMsg.payload, + scheduleAt = javaMsg.scheduleAt, + canUpdate = javaMsg.canUpdate + ) + } +} + +/** Wrapper for batched message operations, associating input metadata with scheduled messages. + * + * @tparam In + * the type of the input metadata + * @tparam A + * the type of the message payload + * @param input + * the original input metadata + * @param message + * the scheduled message + */ +final case class BatchedMessage[+In, +A]( + input: In, + message: ScheduledMessage[A] +) { + + /** Creates a reply for this batched message with the given outcome. */ + def reply(outcome: OfferOutcome): BatchedReply[In, A] = + BatchedReply(input, message, outcome) + + /** Converts this Scala BatchedMessage to a JVM BatchedMessage. */ + def asJava[In1 >: In, A1 >: A]: jvm.BatchedMessage[In1, A1] = + new jvm.BatchedMessage[In1, A1](input, message.asJava) +} + +object BatchedMessage { + + /** Conversion extension for JVM BatchedMessage. */ + extension [In, A](javaMsg: jvm.BatchedMessage[In, A]) { + + /** Converts a JVM BatchedMessage to a Scala BatchedMessage. */ + def asScala: BatchedMessage[In, A] = + BatchedMessage( + input = javaMsg.input, + message = ScheduledMessage.asScala(javaMsg.message) + ) + } +} + +/** Reply for a batched message operation, containing the outcome. + * + * @tparam In + * the type of the input metadata + * @tparam A + * the type of the message payload + * @param input + * the original input metadata + * @param message + * the scheduled message + * @param outcome + * the result of offering this message + */ +final case class BatchedReply[+In, +A]( + input: In, + message: ScheduledMessage[A], + outcome: OfferOutcome +) { + + /** Converts this Scala BatchedReply to a JVM BatchedReply. */ + def asJava[In1 >: In, A1 >: A]: jvm.BatchedReply[In1, A1] = + new jvm.BatchedReply[In1, A1](input, message.asJava, outcome.asJava) +} + +object BatchedReply { + + /** Conversion extension for JVM BatchedReply. */ + extension [In, A](javaReply: jvm.BatchedReply[In, A]) { + + /** Converts a JVM BatchedReply to a Scala BatchedReply. */ + def asScala: BatchedReply[In, A] = + BatchedReply( + input = javaReply.input, + message = ScheduledMessage.asScala(javaReply.message), + outcome = OfferOutcome.asScala(javaReply.outcome) + ) + } +} diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/exceptions.scala b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/exceptions.scala new file mode 100644 index 0000000..1dea0c0 --- /dev/null +++ b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/exceptions.scala @@ -0,0 +1,28 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +/** Checked exception thrown in case of exceptions happening that are not recoverable, rendering + * DelayedQueue inaccessible. + * + * Example: issues with the RDBMS (bugs, or connection unavailable, failing after multiple retries) + */ +class ResourceUnavailableException(message: String, cause: Throwable | Null) + extends Exception(message, cause) { + + def this(message: String) = this(message, null) +} diff --git a/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/CronSpec.scala b/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/CronSpec.scala new file mode 100644 index 0000000..ae7b7ee --- /dev/null +++ b/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/CronSpec.scala @@ -0,0 +1,122 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import java.time.Instant +import java.time.LocalTime +import java.time.ZoneId +import java.time.Duration +import org.funfix.delayedqueue.scala.CronConfigHash.asScala +import org.funfix.delayedqueue.scala.CronMessage.asScala as cronAsScala +import org.funfix.delayedqueue.scala.CronDailySchedule.asScala as scheduleAsScala + +class CronSpec extends munit.FunSuite { + + test("CronConfigHash fromString should be deterministic") { + val text = "test-config" + val hash1 = CronConfigHash.fromString(text) + val hash2 = CronConfigHash.fromString(text) + + assertEquals(hash1.value, hash2.value) + } + + test("CronConfigHash fromDailyCron should create hash") { + val schedule = CronDailySchedule( + zoneId = ZoneId.of("UTC"), + hoursOfDay = List(LocalTime.of(10, 0)), + scheduleInAdvance = Duration.ofDays(1), + scheduleInterval = Duration.ofHours(1) + ) + + val hash = CronConfigHash.fromDailyCron(schedule) + assert(hash.value.nonEmpty) + } + + test("CronConfigHash fromPeriodicTick should create hash") { + val hash = CronConfigHash.fromPeriodicTick(Duration.ofMinutes(5)) + assert(hash.value.nonEmpty) + } + + test("CronMessage key should be unique for different times") { + val hash = CronConfigHash.fromString("test") + val prefix = "test-prefix" + + val key1 = CronMessage.key(hash, prefix, Instant.ofEpochMilli(1000)) + val key2 = CronMessage.key(hash, prefix, Instant.ofEpochMilli(2000)) + + assertNotEquals(key1, key2) + } + + test("CronMessage toScheduled should create ScheduledMessage") { + val cronMsg = CronMessage( + payload = "test-payload", + scheduleAt = Instant.ofEpochMilli(1000) + ) + + val hash = CronConfigHash.fromString("test") + val scheduled = cronMsg.toScheduled(hash, "prefix", canUpdate = true) + + assertEquals(scheduled.payload, "test-payload") + assertEquals(scheduled.scheduleAt, Instant.ofEpochMilli(1000)) + assertEquals(scheduled.canUpdate, true) + } + + test("CronMessage staticPayload should create generator") { + val generator = CronMessage.staticPayload("static-payload") + val instant = Instant.ofEpochMilli(1000) + val cronMsg = generator(instant) + + assertEquals(cronMsg.payload, "static-payload") + assertEquals(cronMsg.scheduleAt, instant) + } + + test("CronDailySchedule getNextTimes should return at least one time") { + val schedule = CronDailySchedule( + zoneId = ZoneId.of("UTC"), + hoursOfDay = List(LocalTime.of(10, 0)), + scheduleInAdvance = Duration.ofDays(1), + scheduleInterval = Duration.ofHours(1) + ) + + val now = Instant.parse("2024-01-01T08:00:00Z") + val nextTimes = schedule.getNextTimes(now) + + assert(nextTimes.nonEmpty) + } + + test("CronDailySchedule should validate non-empty hours") { + intercept[IllegalArgumentException] { + CronDailySchedule( + zoneId = ZoneId.of("UTC"), + hoursOfDay = List.empty, + scheduleInAdvance = Duration.ofDays(1), + scheduleInterval = Duration.ofHours(1) + ) + } + } + + test("CronDailySchedule should validate positive schedule interval") { + intercept[IllegalArgumentException] { + CronDailySchedule( + zoneId = ZoneId.of("UTC"), + hoursOfDay = List(LocalTime.of(10, 0)), + scheduleInAdvance = Duration.ofDays(1), + scheduleInterval = Duration.ZERO + ) + } + } +} diff --git a/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/DataStructuresPropertySpec.scala b/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/DataStructuresPropertySpec.scala new file mode 100644 index 0000000..5858825 --- /dev/null +++ b/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/DataStructuresPropertySpec.scala @@ -0,0 +1,170 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import munit.ScalaCheckSuite +import org.scalacheck.Prop.* +import org.scalacheck.Gen +import scala.concurrent.duration.FiniteDuration +import java.time.{Instant, LocalTime, ZoneId} +import org.funfix.delayedqueue.scala.ScheduledMessage.asScala as scheduledAsScala +import org.funfix.delayedqueue.scala.DelayedQueueTimeConfig.asScala as timeConfigAsScala +import org.funfix.delayedqueue.scala.MessageId.asScala as messageIdAsScala +import org.funfix.delayedqueue.scala.OfferOutcome.asScala as offerOutcomeAsScala +import org.funfix.delayedqueue.scala.Generators.{given, *} + +class DataStructuresPropertySpec extends ScalaCheckSuite { + property("ScheduledMessage asJava/asScala roundtrip preserves data") { + forAll { (key: String, payload: String, instant: Instant, canUpdate: Boolean) => + val original = ScheduledMessage(key, payload, instant, canUpdate) + val roundtripped = original.asJava.scheduledAsScala + + assertEquals(roundtripped.key, original.key) + assertEquals(roundtripped.payload, original.payload) + assertEquals(roundtripped.scheduleAt, original.scheduleAt) + assertEquals(roundtripped.canUpdate, original.canUpdate) + } + } + + property("DelayedQueueTimeConfig asJava/asScala roundtrip preserves data") { + forAll { (acquireTimeout: FiniteDuration, pollPeriod: FiniteDuration) => + val original = DelayedQueueTimeConfig(acquireTimeout, pollPeriod) + val roundtripped = original.asJava.timeConfigAsScala + + assertEquals(roundtripped.acquireTimeout.toMillis, original.acquireTimeout.toMillis) + assertEquals(roundtripped.pollPeriod.toMillis, original.pollPeriod.toMillis) + } + } + + property("MessageId is symmetric") { + forAll { (value: String) => + val messageId = MessageId(value) + assertEquals(messageId.value, value) + assertEquals(messageId.asJava.messageIdAsScala.value, value) + } + } + + property("CronConfigHash fromString is deterministic") { + forAll { (input: String) => + val hash1 = CronConfigHash.fromString(input) + val hash2 = CronConfigHash.fromString(input) + assertEquals(hash1.value, hash2.value) + } + } + + property("CronMessage key is unique for different times") { + forAll { (instant1: Instant, instant2: Instant) => + if instant1 != instant2 then { + val hash = CronConfigHash.fromString("test") + val prefix = "test-prefix" + val key1 = CronMessage.key(hash, prefix, instant1) + val key2 = CronMessage.key(hash, prefix, instant2) + assertNotEquals(key1, key2) + } + } + } + + property("BatchedMessage covariance works correctly") { + forAll { (input: Int, payload: String, instant: Instant) => + val message = ScheduledMessage(s"key-$input", payload, instant) + val batched: BatchedMessage[Int, String] = BatchedMessage(input, message) + // Covariance allows us to upcast + val widened: BatchedMessage[Any, Any] = batched + assertEquals(widened.input, input) + } + } + + property("BatchedReply covariance works correctly") { + forAll { (input: Int, payload: String, instant: Instant) => + val message = ScheduledMessage(s"key-$input", payload, instant) + val reply: BatchedReply[Int, String] = BatchedReply(input, message, OfferOutcome.Created) + // Covariance allows us to upcast + val widened: BatchedReply[Any, Any] = reply + assertEquals(widened.input, input) + } + } + + property("CronDailySchedule getNextTimes always returns at least one time") { + forAll { (hours: List[LocalTime], now: Instant, zoneId: ZoneId) => + if hours.nonEmpty then { + val schedule = CronDailySchedule( + zoneId = zoneId, + hoursOfDay = hours, + scheduleInAdvance = java.time.Duration.ofDays(1), + scheduleInterval = java.time.Duration.ofHours(1) + ) + val nextTimes = schedule.getNextTimes(now) + assert(nextTimes.nonEmpty, "getNextTimes should return at least one time") + assert(nextTimes.head.isAfter(now) || nextTimes.head == now, "First time should be >= now") + } + } + } + + property("OfferOutcome asJava/asScala roundtrip") { + forAll(Gen.oneOf(OfferOutcome.Created, OfferOutcome.Updated, OfferOutcome.Ignored)) { + outcome => + val roundtripped = outcome.asJava.offerOutcomeAsScala + assertEquals(roundtripped, outcome) + } + } + + property("DeliveryType asJava/asScala roundtrip") { + import org.funfix.delayedqueue.scala.DeliveryType.asScala + forAll(Gen.oneOf(DeliveryType.FirstDelivery, DeliveryType.Redelivery)) { deliveryType => + val roundtripped = deliveryType.asJava.asScala + assertEquals(roundtripped, deliveryType) + } + } + + property("RetryConfig validates backoffFactor >= 1.0") { + forAll { (backoffFactor: Double) => + // Simplified test: just check that invalid backoff factors are rejected + if backoffFactor < 1.0 && !backoffFactor.isNaN && !backoffFactor.isInfinite then { + val _ = intercept[IllegalArgumentException] { + RetryConfig( + initialDelay = java.time.Duration.ofMillis(100), + maxDelay = java.time.Duration.ofMillis(1000), + backoffFactor = backoffFactor, + maxRetries = None, + totalSoftTimeout = None, + perTryHardTimeout = None + ) + } + () + } else if backoffFactor >= 1.0 && !backoffFactor.isNaN && !backoffFactor.isInfinite then { + val config = RetryConfig( + initialDelay = java.time.Duration.ofMillis(100), + maxDelay = java.time.Duration.ofMillis(1000), + backoffFactor = backoffFactor, + maxRetries = None, + totalSoftTimeout = None, + perTryHardTimeout = None + ) + assertEquals(config.backoffFactor >= 1.0, true) + } + } + } + + property("JdbcDriver fromClassName is case-insensitive") { + forAll(Gen.oneOf(JdbcDriver.entries)) { driver => + val lower = JdbcDriver.fromClassName(driver.className.toLowerCase) + val upper = JdbcDriver.fromClassName(driver.className.toUpperCase) + assertEquals(lower, Some(driver)) + assertEquals(upper, Some(driver)) + } + } +} diff --git a/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/Generators.scala b/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/Generators.scala new file mode 100644 index 0000000..4da365c --- /dev/null +++ b/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/Generators.scala @@ -0,0 +1,44 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import org.scalacheck.{Arbitrary, Gen} +import scala.concurrent.duration.* +import java.time.{Instant, LocalTime, ZoneId} + +/** Common ScalaCheck generators for DelayedQueue data structures. */ +object Generators { + + implicit val arbFiniteDuration: Arbitrary[FiniteDuration] = Arbitrary( + Gen.choose(0L, 1000000L).map(millis => FiniteDuration(millis, MILLISECONDS)) + ) + + implicit val arbInstant: Arbitrary[Instant] = Arbitrary( + Gen.choose(0L, System.currentTimeMillis() * 2).map(Instant.ofEpochMilli) + ) + + implicit val arbLocalTime: Arbitrary[LocalTime] = Arbitrary( + for { + hour <- Gen.choose(0, 23) + minute <- Gen.choose(0, 59) + } yield LocalTime.of(hour, minute) + ) + + implicit val arbZoneId: Arbitrary[ZoneId] = Arbitrary( + Gen.oneOf(ZoneId.of("UTC"), ZoneId.of("America/New_York"), ZoneId.of("Europe/London")) + ) +} diff --git a/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/JdbcDriverSpec.scala b/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/JdbcDriverSpec.scala new file mode 100644 index 0000000..707cb05 --- /dev/null +++ b/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/JdbcDriverSpec.scala @@ -0,0 +1,55 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import org.funfix.delayedqueue.scala.JdbcDriver.asScala + +class JdbcDriverSpec extends munit.FunSuite { + + test("JdbcDriver constants should have correct class names") { + assertEquals(JdbcDriver.HSQLDB.className, "org.hsqldb.jdbc.JDBCDriver") + assertEquals(JdbcDriver.H2.className, "org.h2.Driver") + assertEquals(JdbcDriver.PostgreSQL.className, "org.postgresql.Driver") + assertEquals(JdbcDriver.MySQL.className, "com.mysql.cj.jdbc.Driver") + assertEquals(JdbcDriver.MariaDB.className, "org.mariadb.jdbc.Driver") + assertEquals(JdbcDriver.Sqlite.className, "org.sqlite.JDBC") + assertEquals(JdbcDriver.MsSqlServer.className, "com.microsoft.sqlserver.jdbc.SQLServerDriver") + assertEquals(JdbcDriver.Oracle.className, "oracle.jdbc.OracleDriver") + } + + test("JdbcDriver entries should contain all drivers") { + val allDrivers = Set( + JdbcDriver.HSQLDB, + JdbcDriver.H2, + JdbcDriver.PostgreSQL, + JdbcDriver.MySQL, + JdbcDriver.MariaDB, + JdbcDriver.Sqlite, + JdbcDriver.MsSqlServer, + JdbcDriver.Oracle + ) + + assertEquals(JdbcDriver.entries.toSet, allDrivers) + } + + test("asJava and asScala should be symmetric") { + JdbcDriver.entries.foreach { driver => + val roundtripped = driver.asJava.asScala + assertEquals(roundtripped.className, driver.className) + } + } +} diff --git a/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/RetryConfigSpec.scala b/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/RetryConfigSpec.scala new file mode 100644 index 0000000..055299e --- /dev/null +++ b/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/RetryConfigSpec.scala @@ -0,0 +1,78 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import java.time.Duration +import org.funfix.delayedqueue.jvm +import org.funfix.delayedqueue.scala.RetryConfig.asScala + +class RetryConfigSpec extends munit.FunSuite { + + test("DEFAULT should have correct values") { + assertEquals(RetryConfig.DEFAULT.maxRetries, Some(5L)) + assertEquals(RetryConfig.DEFAULT.totalSoftTimeout, Some(Duration.ofSeconds(30))) + assertEquals(RetryConfig.DEFAULT.perTryHardTimeout, Some(Duration.ofSeconds(10))) + assertEquals(RetryConfig.DEFAULT.initialDelay, Duration.ofMillis(100)) + assertEquals(RetryConfig.DEFAULT.maxDelay, Duration.ofSeconds(5)) + assertEquals(RetryConfig.DEFAULT.backoffFactor, 2.0) + } + + test("NO_RETRIES should have correct values") { + assertEquals(RetryConfig.NO_RETRIES.maxRetries, Some(0L)) + assertEquals(RetryConfig.NO_RETRIES.totalSoftTimeout, None) + assertEquals(RetryConfig.NO_RETRIES.perTryHardTimeout, None) + assertEquals(RetryConfig.NO_RETRIES.backoffFactor, 1.0) + } + + test("asJava and asScala should be symmetric") { + val original = RetryConfig.DEFAULT + val roundtripped = original.asJava.asScala + + assertEquals(roundtripped.maxRetries, original.maxRetries) + assertEquals(roundtripped.totalSoftTimeout, original.totalSoftTimeout) + assertEquals(roundtripped.perTryHardTimeout, original.perTryHardTimeout) + assertEquals(roundtripped.initialDelay, original.initialDelay) + assertEquals(roundtripped.maxDelay, original.maxDelay) + assertEquals(roundtripped.backoffFactor, original.backoffFactor) + } + + test("should validate backoffFactor >= 1.0") { + intercept[IllegalArgumentException] { + RetryConfig( + initialDelay = Duration.ofMillis(100), + maxDelay = Duration.ofSeconds(5), + backoffFactor = 0.5, + maxRetries = None, + totalSoftTimeout = None, + perTryHardTimeout = None + ) + } + } + + test("should validate non-negative delays") { + intercept[IllegalArgumentException] { + RetryConfig( + initialDelay = Duration.ofMillis(-100), + maxDelay = Duration.ofSeconds(5), + backoffFactor = 2.0, + maxRetries = None, + totalSoftTimeout = None, + perTryHardTimeout = None + ) + } + } +} diff --git a/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/ScheduledMessageSpec.scala b/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/ScheduledMessageSpec.scala new file mode 100644 index 0000000..ffa6515 --- /dev/null +++ b/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/ScheduledMessageSpec.scala @@ -0,0 +1,70 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import java.time.Instant +import org.funfix.delayedqueue.scala.ScheduledMessage.asScala +import org.funfix.delayedqueue.scala.OfferOutcome.asScala as offerAsScala + +class ScheduledMessageSpec extends munit.FunSuite { + + test("ScheduledMessage asJava and asScala should be symmetric") { + val original = ScheduledMessage( + key = "test-key", + payload = "test-payload", + scheduleAt = Instant.ofEpochMilli(1000), + canUpdate = true + ) + + val roundtripped = original.asJava.asScala + + assertEquals(roundtripped.key, original.key) + assertEquals(roundtripped.payload, original.payload) + assertEquals(roundtripped.scheduleAt, original.scheduleAt) + assertEquals(roundtripped.canUpdate, original.canUpdate) + } + + test("BatchedMessage reply should create BatchedReply") { + val message = ScheduledMessage( + key = "test-key", + payload = "test-payload", + scheduleAt = Instant.ofEpochMilli(1000) + ) + + val batched = BatchedMessage(input = 42, message = message) + val reply = batched.reply(OfferOutcome.Created) + + assertEquals(reply.input, 42) + assertEquals(reply.message, message) + assertEquals(reply.outcome, OfferOutcome.Created) + } + + test("OfferOutcome isIgnored should work correctly") { + assert(OfferOutcome.Ignored.isIgnored) + assert(!OfferOutcome.Created.isIgnored) + assert(!OfferOutcome.Updated.isIgnored) + } + + test("OfferOutcome asJava and asScala should be symmetric") { + val outcomes = List(OfferOutcome.Created, OfferOutcome.Updated, OfferOutcome.Ignored) + + outcomes.foreach { outcome => + val roundtripped = outcome.asJava.offerAsScala + assertEquals(roundtripped, outcome) + } + } +} From 4caa87b9d31f144f6ea5bd450193a38364857b6d Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 8 Feb 2026 19:11:45 +0200 Subject: [PATCH 09/12] API --- .../funfix/delayedqueue/jvm/CronService.kt | 15 +- .../delayedqueue/jvm/DelayedQueueJDBC.kt | 7 +- .../jvm/internals/CronServiceImpl.kt | 3 - .../delayedqueue/scala/CronMessage.scala | 3 - .../delayedqueue/scala/CronService.scala | 149 ++++++++++++++++++ .../delayedqueue/scala/DelayedQueue.scala | 134 ++++++++++++++++ 6 files changed, 293 insertions(+), 18 deletions(-) create mode 100644 delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronService.scala create mode 100644 delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueue.scala diff --git a/delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/CronService.kt b/delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/CronService.kt index e61d5a5..895228c 100644 --- a/delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/CronService.kt +++ b/delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/CronService.kt @@ -73,15 +73,16 @@ public interface CronService { * When the configuration changes (detected by hash comparison), old messages are automatically * removed and new ones are scheduled. * + * NOTE: This doesn't raise any `ResourceUnavailableException` or `InterruptedException` because + * it's a background process that keeps on running in a loop (hence the `AutoCloseable`), so + * exceptions get logged. + * * @param configHash hash of the configuration (for detecting changes) * @param keyPrefix unique prefix for generated message keys * @param scheduleInterval how often to regenerate/update the schedule * @param generateMany function that generates messages based on current time * @return an AutoCloseable resource that should be closed to stop scheduling - * @throws ResourceUnavailableException if database operation fails after retries - * @throws InterruptedException if the operation is interrupted */ - @Throws(ResourceUnavailableException::class, InterruptedException::class) public fun install( configHash: CronConfigHash, keyPrefix: String, @@ -95,14 +96,15 @@ public interface CronService { * This method starts a background process that schedules messages for specific hours each day. * The schedule configuration determines when messages are generated. * + * NOTE: This doesn't raise any `ResourceUnavailableException` or `InterruptedException` because + * it's a background process that keeps on running in a loop (hence the `AutoCloseable`), so + * exceptions get logged. + * * @param keyPrefix unique prefix for generated message keys * @param schedule daily schedule configuration (hours, timezone, advance scheduling) * @param generator function that creates a message for a given future instant * @return an AutoCloseable resource that should be closed to stop scheduling - * @throws ResourceUnavailableException if database operation fails after retries - * @throws InterruptedException if the operation is interrupted */ - @Throws(ResourceUnavailableException::class, InterruptedException::class) public fun installDailySchedule( keyPrefix: String, schedule: CronDailySchedule, @@ -122,7 +124,6 @@ public interface CronService { * @throws ResourceUnavailableException if database operation fails after retries * @throws InterruptedException if the operation is interrupted */ - @Throws(ResourceUnavailableException::class, InterruptedException::class) public fun installPeriodicTick( keyPrefix: String, period: Duration, diff --git a/delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/DelayedQueueJDBC.kt b/delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/DelayedQueueJDBC.kt index b035928..769d15e 100644 --- a/delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/DelayedQueueJDBC.kt +++ b/delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/DelayedQueueJDBC.kt @@ -661,20 +661,17 @@ private constructor( * policy) * @param clock optional clock for time operations (uses system UTC if not provided) * @return a new DelayedQueueJDBC instance - * @throws ResourceUnavailableException if database initialization fails - * @throws InterruptedException if interrupted during initialization */ @JvmStatic @JvmOverloads - @Throws(ResourceUnavailableException::class, InterruptedException::class) public fun create( serializer: MessageSerializer, config: DelayedQueueJDBCConfig, clock: Clock = Clock.systemUTC(), - ): DelayedQueueJDBC = unsafeSneakyRaises { + ): DelayedQueueJDBC { val database = Database(config.db) val adapter = SQLVendorAdapter.create(config.db.driver, config.tableName) - DelayedQueueJDBC( + return DelayedQueueJDBC( database = database, adapter = adapter, serializer = serializer, diff --git a/delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/internals/CronServiceImpl.kt b/delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/internals/CronServiceImpl.kt index 0352650..2e0efcc 100644 --- a/delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/internals/CronServiceImpl.kt +++ b/delayedqueue-jvm/src/main/kotlin/org/funfix/delayedqueue/jvm/internals/CronServiceImpl.kt @@ -81,7 +81,6 @@ internal class CronServiceImpl( unsafeSneakyRaises { deleteCurrentCron(configHash, keyPrefix) } } - @Throws(ResourceUnavailableException::class, InterruptedException::class) override fun install( configHash: CronConfigHash, keyPrefix: String, @@ -95,7 +94,6 @@ internal class CronServiceImpl( generateMany = generateMany, ) - @Throws(ResourceUnavailableException::class, InterruptedException::class) override fun installDailySchedule( keyPrefix: String, schedule: CronDailySchedule, @@ -110,7 +108,6 @@ internal class CronServiceImpl( }, ) - @Throws(ResourceUnavailableException::class, InterruptedException::class) override fun installPeriodicTick( keyPrefix: String, period: Duration, diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronMessage.scala b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronMessage.scala index 987964e..584ce0b 100644 --- a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronMessage.scala +++ b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronMessage.scala @@ -17,9 +17,6 @@ package org.funfix.delayedqueue.scala import java.time.Instant -import java.time.ZoneOffset -import java.time.format.DateTimeFormatter -import java.util.Locale import org.funfix.delayedqueue.jvm /** Represents a message for periodic (cron-like) scheduling. diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronService.scala b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronService.scala new file mode 100644 index 0000000..fccbdb1 --- /dev/null +++ b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronService.scala @@ -0,0 +1,149 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import cats.effect.IO +import cats.effect.Resource +import cats.mtl.Raise +import java.time.Duration +import java.time.Instant + +/** Service for installing cron-like periodic schedules in a delayed queue. + * + * This service allows installing tasks that execute at regular intervals or at specific times each + * day. Configuration changes are detected via hash comparisons, allowing automatic cleanup of + * obsolete schedules. + * + * @tparam A + * the type of message payload + */ +trait CronService[A] { + + /** Installs a one-time set of future scheduled messages. + * + * This method is useful for installing a fixed set of future events that belong to a specific + * configuration (e.g., from a database or config file). + * + * The configHash and keyPrefix are used to identify and clean up messages when configurations + * are updated or deleted. + * + * @param configHash + * hash identifying this configuration (for detecting changes) + * @param keyPrefix + * prefix for all message keys in this configuration + * @param messages + * list of messages to schedule + */ + def installTick( + configHash: CronConfigHash, + keyPrefix: String, + messages: List[CronMessage[A]] + )(using Raise[IO, ResourceUnavailableException]): IO[Unit] + + /** Uninstalls all future messages for a specific cron configuration. + * + * This removes all scheduled messages that match the given configHash and keyPrefix. + * + * @param configHash + * hash identifying the configuration to remove + * @param keyPrefix + * prefix for message keys to remove + */ + def uninstallTick(configHash: CronConfigHash, keyPrefix: String)(using + Raise[IO, ResourceUnavailableException] + ): IO[Unit] + + /** Installs a cron-like schedule where messages are generated at intervals. + * + * This method starts a background process that periodically generates and schedules messages. + * The returned Resource should be used to manage the lifecycle of the background process. + * + * When the configuration changes (detected by hash comparison), old messages are automatically + * removed and new ones are scheduled. + * + * @param configHash + * hash of the configuration (for detecting changes) + * @param keyPrefix + * unique prefix for generated message keys + * @param scheduleInterval + * how often to regenerate/update the schedule + * @param generateMany + * function that generates messages based on current time + * @return + * a Resource that manages the lifecycle of the background scheduling process + */ + def install( + configHash: CronConfigHash, + keyPrefix: String, + scheduleInterval: Duration, + generateMany: CronMessageBatchGenerator[A] + ): Resource[IO, Unit] + + /** Installs a daily schedule with timezone-aware execution times. + * + * This method starts a background process that schedules messages for specific hours each day. + * The schedule configuration determines when messages are generated. + * + * @param keyPrefix + * unique prefix for generated message keys + * @param schedule + * daily schedule configuration (hours, timezone, advance scheduling) + * @param generator + * function that creates a message for a given future instant + * @return + * a Resource that manages the lifecycle of the background scheduling process + */ + def installDailySchedule( + keyPrefix: String, + schedule: CronDailySchedule, + generator: CronMessageGenerator[A] + ): Resource[IO, Unit] + + /** Installs a periodic tick that generates messages at fixed intervals. + * + * This method starts a background process that generates a new message every `period` duration. + * The generator receives the scheduled time and produces the payload. + * + * @param keyPrefix + * unique prefix for generated message keys + * @param period + * interval between generated messages + * @param generator + * function that creates a payload for a given instant + * @return + * a Resource that manages the lifecycle of the background scheduling process + */ + def installPeriodicTick( + keyPrefix: String, + period: Duration, + generator: CronPayloadGenerator[A] + ): Resource[IO, Unit] +} + +/** Generates a batch of cron messages based on the current instant. */ +trait CronMessageBatchGenerator[A] { + + /** Creates a batch of cron messages. */ + def apply(now: Instant): List[CronMessage[A]] +} + +/** Generates a payload for a given instant. */ +trait CronPayloadGenerator[A] { + + /** Creates a payload for the given instant. */ + def apply(at: Instant): A +} diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueue.scala b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueue.scala new file mode 100644 index 0000000..18b287b --- /dev/null +++ b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueue.scala @@ -0,0 +1,134 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import cats.effect.IO +import cats.mtl.Raise +import java.time.Instant + +/** A delayed queue for scheduled message processing with FIFO semantics. + * + * @tparam A + * the type of message payloads stored in the queue + */ +trait DelayedQueue[A] { + + /** Returns the [DelayedQueueTimeConfig] with which this instance was initialized. */ + def getTimeConfig: IO[DelayedQueueTimeConfig] + + /** Offers a message for processing, at a specific timestamp. + * + * In case the key already exists, an update is attempted. + * + * @param key + * identifies the message; can be a "transaction ID" that could later be used for deleting the + * message in advance + * @param payload + * is the message being delivered + * @param scheduleAt + * specifies when the message will become available for `poll` and processing + */ + def offerOrUpdate(key: String, payload: A, scheduleAt: Instant)(using + Raise[IO, ResourceUnavailableException] + ): IO[OfferOutcome] + + /** Version of [offerOrUpdate] that only creates new entries and does not allow updates. */ + def offerIfNotExists(key: String, payload: A, scheduleAt: Instant)(using + Raise[IO, ResourceUnavailableException] + ): IO[OfferOutcome] + + /** Batched version of offer operations. + * + * @param In + * is the type of the input message, corresponding to each [ScheduledMessage]. This helps in + * streaming the original input messages after processing the batch. + */ + def offerBatch[In](messages: List[BatchedMessage[In, A]])(using + Raise[IO, ResourceUnavailableException] + ): IO[List[BatchedReply[In, A]]] + + /** Pulls the first message to process from the queue (FIFO), returning `None` in case no such + * message is available. + * + * This method locks the message for processing, making it invisible for other consumers (until + * the configured timeout happens). + */ + def tryPoll(using Raise[IO, ResourceUnavailableException]): IO[Option[AckEnvelope[A]]] + + /** Pulls a batch of messages to process from the queue (FIFO), returning an empty list in case no + * such messages are available. + * + * WARNING: don't abuse the number of messages requested. E.g., a large number, such as 20000, + * can still lead to serious performance issues. + * + * @param batchMaxSize + * is the maximum number of messages that can be returned in a single batch; the actual number + * of returned messages can be smaller than this value, depending on how many messages are + * available at the time of polling + */ + def tryPollMany(batchMaxSize: Int)(using + Raise[IO, ResourceUnavailableException] + ): IO[AckEnvelope[List[A]]] + + /** Extracts the next event from the delayed-queue, or waits until there's such an event + * available. + */ + def poll(using Raise[IO, ResourceUnavailableException]): IO[AckEnvelope[A]] + + /** Reads a message from the queue, corresponding to the given `key`, without locking it for + * processing. + * + * This is unlike [tryPoll] or [poll], because multiple consumers can read the same message. Use + * with care, because processing a message retrieved via [read] does not guarantee that the + * message will be processed only once. + * + * WARNING: this operation invalidates the model of the queue. DO NOT USE! This is because + * multiple consumers can process the same message, leading to potential issues. + */ + def read(key: String)(using Raise[IO, ResourceUnavailableException]): IO[Option[AckEnvelope[A]]] + + /** Deletes a message from the queue that's associated with the given `key`. */ + def dropMessage(key: String)(using Raise[IO, ResourceUnavailableException]): IO[Boolean] + + /** Checks that a message exists in the queue. + * + * @param key + * identifies the message + * @return + * `true` in case a message with the given `key` exists in the queue, `false` otherwise + */ + def containsMessage(key: String)(using Raise[IO, ResourceUnavailableException]): IO[Boolean] + + /** Drops all existing enqueued messages. + * + * This deletes all messages from the DB table of the configured type. + * + * WARN: This is a dangerous operation, because it can lead to data loss. Use with care, i.e., + * only for testing! + * + * @param confirm + * must be exactly "Yes, please, I know what I'm doing!" to proceed + * @return + * the number of messages deleted + */ + def dropAllMessages(confirm: String)(using + Raise[IO, ResourceUnavailableException] + ): IO[Int] + + /** Utilities for installing cron-like schedules. */ + def getCron: IO[CronService[A]] +} From 43ad99b06c06133b9d722fc4ef1e966be3d9c1dc Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 8 Feb 2026 19:17:23 +0200 Subject: [PATCH 10/12] Remove Cats-MTL from project --- build.sbt | 1 - delayedqueue-scala/AGENTS.md | 4 +-- .../delayedqueue/scala/CronService.scala | 7 ++--- .../delayedqueue/scala/DelayedQueue.scala | 31 ++++++------------- 4 files changed, 13 insertions(+), 30 deletions(-) diff --git a/build.sbt b/build.sbt index 323d9fc..7f517ab 100644 --- a/build.sbt +++ b/build.sbt @@ -74,7 +74,6 @@ lazy val delayedqueue = crossProject(JVMPlatform) libraryDependencies ++= Seq( "org.funfix" % "delayedqueue-jvm" % version.value, "org.typelevel" %% "cats-effect" % "3.6.3", - "org.typelevel" %% "cats-mtl" % "1.4.0", // Testing "org.scalameta" %% "munit" % "1.0.4" % Test, "org.typelevel" %% "munit-cats-effect" % "2.1.0" % Test, diff --git a/delayedqueue-scala/AGENTS.md b/delayedqueue-scala/AGENTS.md index 8679814..410e10f 100644 --- a/delayedqueue-scala/AGENTS.md +++ b/delayedqueue-scala/AGENTS.md @@ -12,8 +12,7 @@ The public API should be idiomatic Scala, leveraging Scala 3 features where appr ## API design principles - Use immutable data structures by default (from `scala.collection.immutable`). -- For error handling, use `Either` or `Option` for pure functions, and use Cats-MTL for error handling in effectful functions (returning `IO` or `F[_]`). Avoid `EitherT[IO, E, A]` or `IO[Either[E, A]]`. - - Use skill: `cats-mtl-typed-errors` +- For error handling, use `Either` or `Option` for pure functions. For effectful functions (returning `IO`), handle errors within the effect using `IO[Either[E, A]]` or by raising exceptions within `IO`. Avoid `EitherT[IO, E, A]` and avoid throwing exceptions outside of `IO` contexts. - Design for composition: small, focused functions/methods. - Make illegal states unrepresentable with types. @@ -59,4 +58,3 @@ The public API should be idiomatic Scala, leveraging Scala 3 features where appr - Skills: - `.agents/skills/cats-effect-io` - `.agents/skills/cats-effect-resource` - - `.agents/skills/cats-mtl-typed-errors` diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronService.scala b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronService.scala index fccbdb1..09c43f9 100644 --- a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronService.scala +++ b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronService.scala @@ -18,7 +18,6 @@ package org.funfix.delayedqueue.scala import cats.effect.IO import cats.effect.Resource -import cats.mtl.Raise import java.time.Duration import java.time.Instant @@ -52,7 +51,7 @@ trait CronService[A] { configHash: CronConfigHash, keyPrefix: String, messages: List[CronMessage[A]] - )(using Raise[IO, ResourceUnavailableException]): IO[Unit] + ): IO[Unit] /** Uninstalls all future messages for a specific cron configuration. * @@ -63,9 +62,7 @@ trait CronService[A] { * @param keyPrefix * prefix for message keys to remove */ - def uninstallTick(configHash: CronConfigHash, keyPrefix: String)(using - Raise[IO, ResourceUnavailableException] - ): IO[Unit] + def uninstallTick(configHash: CronConfigHash, keyPrefix: String): IO[Unit] /** Installs a cron-like schedule where messages are generated at intervals. * diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueue.scala b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueue.scala index 18b287b..05ee1de 100644 --- a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueue.scala +++ b/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueue.scala @@ -17,7 +17,6 @@ package org.funfix.delayedqueue.scala import cats.effect.IO -import cats.mtl.Raise import java.time.Instant /** A delayed queue for scheduled message processing with FIFO semantics. @@ -42,14 +41,10 @@ trait DelayedQueue[A] { * @param scheduleAt * specifies when the message will become available for `poll` and processing */ - def offerOrUpdate(key: String, payload: A, scheduleAt: Instant)(using - Raise[IO, ResourceUnavailableException] - ): IO[OfferOutcome] + def offerOrUpdate(key: String, payload: A, scheduleAt: Instant): IO[OfferOutcome] /** Version of [offerOrUpdate] that only creates new entries and does not allow updates. */ - def offerIfNotExists(key: String, payload: A, scheduleAt: Instant)(using - Raise[IO, ResourceUnavailableException] - ): IO[OfferOutcome] + def offerIfNotExists(key: String, payload: A, scheduleAt: Instant): IO[OfferOutcome] /** Batched version of offer operations. * @@ -57,9 +52,7 @@ trait DelayedQueue[A] { * is the type of the input message, corresponding to each [ScheduledMessage]. This helps in * streaming the original input messages after processing the batch. */ - def offerBatch[In](messages: List[BatchedMessage[In, A]])(using - Raise[IO, ResourceUnavailableException] - ): IO[List[BatchedReply[In, A]]] + def offerBatch[In](messages: List[BatchedMessage[In, A]]): IO[List[BatchedReply[In, A]]] /** Pulls the first message to process from the queue (FIFO), returning `None` in case no such * message is available. @@ -67,7 +60,7 @@ trait DelayedQueue[A] { * This method locks the message for processing, making it invisible for other consumers (until * the configured timeout happens). */ - def tryPoll(using Raise[IO, ResourceUnavailableException]): IO[Option[AckEnvelope[A]]] + def tryPoll: IO[Option[AckEnvelope[A]]] /** Pulls a batch of messages to process from the queue (FIFO), returning an empty list in case no * such messages are available. @@ -80,14 +73,12 @@ trait DelayedQueue[A] { * of returned messages can be smaller than this value, depending on how many messages are * available at the time of polling */ - def tryPollMany(batchMaxSize: Int)(using - Raise[IO, ResourceUnavailableException] - ): IO[AckEnvelope[List[A]]] + def tryPollMany(batchMaxSize: Int): IO[AckEnvelope[List[A]]] /** Extracts the next event from the delayed-queue, or waits until there's such an event * available. */ - def poll(using Raise[IO, ResourceUnavailableException]): IO[AckEnvelope[A]] + def poll: IO[AckEnvelope[A]] /** Reads a message from the queue, corresponding to the given `key`, without locking it for * processing. @@ -99,10 +90,10 @@ trait DelayedQueue[A] { * WARNING: this operation invalidates the model of the queue. DO NOT USE! This is because * multiple consumers can process the same message, leading to potential issues. */ - def read(key: String)(using Raise[IO, ResourceUnavailableException]): IO[Option[AckEnvelope[A]]] + def read(key: String): IO[Option[AckEnvelope[A]]] /** Deletes a message from the queue that's associated with the given `key`. */ - def dropMessage(key: String)(using Raise[IO, ResourceUnavailableException]): IO[Boolean] + def dropMessage(key: String): IO[Boolean] /** Checks that a message exists in the queue. * @@ -111,7 +102,7 @@ trait DelayedQueue[A] { * @return * `true` in case a message with the given `key` exists in the queue, `false` otherwise */ - def containsMessage(key: String)(using Raise[IO, ResourceUnavailableException]): IO[Boolean] + def containsMessage(key: String): IO[Boolean] /** Drops all existing enqueued messages. * @@ -125,9 +116,7 @@ trait DelayedQueue[A] { * @return * the number of messages deleted */ - def dropAllMessages(confirm: String)(using - Raise[IO, ResourceUnavailableException] - ): IO[Int] + def dropAllMessages(confirm: String): IO[Int] /** Utilities for installing cron-like schedules. */ def getCron: IO[CronService[A]] From 502c717d8cb304bcd63e2f63f2a050a36ae3c258 Mon Sep 17 00:00:00 2001 From: Alexandru Nedelcu Date: Sun, 8 Feb 2026 19:42:00 +0200 Subject: [PATCH 11/12] Add Makefile utilities --- Makefile | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index eae93f7..92772af 100644 --- a/Makefile +++ b/Makefile @@ -22,5 +22,26 @@ dependency-updates: update-gradle: ./gradlew wrapper --gradle-version latest -test-watch: - ./gradlew -t check +format-scala: + ./sbt scalafmtAll + +format-kotlin: + ./gradlew ktfmtFormat + +test-scala: + ./sbt "testQuick" + +test-scala-watch: + ./sbt "~testQuick" + +test-kotlin: + ./gradlew test + +test-kotlin-watch: + ./gradlew -t test + +test: + ./gradlew test && ./sbt testQuick + +check-all: + ./gradlew check && ./sbt check From 74e9cf64e49397aba563efa12d11d8c69419a24b Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 9 Feb 2026 16:00:11 +0200 Subject: [PATCH 12/12] Implement DelayedQueueInMemory Scala wrapper (#21) * Initial plan * Implement DelayedQueueInMemory Scala wrapper with tests - Created DelayedQueueInMemory.scala wrapping JVM implementation - Implemented all DelayedQueue trait methods with proper type conversions - Added CronService wrapper for cron functionality - Created comprehensive test suite (19 tests, all passing) - Follows existing patterns (asScala/asJava conversions) - Uses Cats Effect IO for side effects management Co-authored-by: alexandru <11753+alexandru@users.noreply.github.com> * Change to F[_] * Fix API again * Reformatting * Remove junk * Add concurrency test --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: alexandru <11753+alexandru@users.noreply.github.com> Co-authored-by: Alexandru Nedelcu --- build.sbt | 10 +- .../delayedqueue/scala/AckEnvelope.scala | 0 .../delayedqueue/scala/CronConfigHash.scala | 0 .../scala/CronDailySchedule.scala | 0 .../delayedqueue/scala/CronMessage.scala | 0 .../delayedqueue/scala/CronService.scala | 23 +- .../delayedqueue/scala/DelayedQueue.scala | 2 +- .../scala/DelayedQueueInMemory.scala | 239 ++++++++++++ .../scala/DelayedQueueJDBCConfig.scala | 0 .../scala/DelayedQueueTimeConfig.scala | 0 .../scala/JdbcConnectionConfig.scala | 0 .../scala/JdbcDatabasePoolConfig.scala | 0 .../delayedqueue/scala/JdbcDriver.scala | 0 .../delayedqueue/scala/OfferOutcome.scala | 0 .../delayedqueue/scala/RetryConfig.scala | 0 .../delayedqueue/scala/ScheduledMessage.scala | 0 .../delayedqueue/scala/exceptions.scala | 0 .../org/funfix/delayedqueue/scala/hello.scala | 0 .../funfix/delayedqueue/scala/CronSpec.scala | 0 .../scala/DataStructuresPropertySpec.scala | 0 .../scala/DelayedQueueInMemorySpec.scala | 356 ++++++++++++++++++ .../delayedqueue/scala/Generators.scala | 0 .../delayedqueue/scala/HelloSuite.scala | 0 .../delayedqueue/scala/JdbcDriverSpec.scala | 0 .../delayedqueue/scala/RetryConfigSpec.scala | 0 .../scala/ScheduledMessageSpec.scala | 0 project/plugins.sbt | 4 - 27 files changed, 603 insertions(+), 31 deletions(-) rename delayedqueue-scala/{jvm => }/src/main/scala/org/funfix/delayedqueue/scala/AckEnvelope.scala (100%) rename delayedqueue-scala/{jvm => }/src/main/scala/org/funfix/delayedqueue/scala/CronConfigHash.scala (100%) rename delayedqueue-scala/{jvm => }/src/main/scala/org/funfix/delayedqueue/scala/CronDailySchedule.scala (100%) rename delayedqueue-scala/{jvm => }/src/main/scala/org/funfix/delayedqueue/scala/CronMessage.scala (100%) rename delayedqueue-scala/{jvm => }/src/main/scala/org/funfix/delayedqueue/scala/CronService.scala (89%) rename delayedqueue-scala/{jvm => }/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueue.scala (99%) create mode 100644 delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueInMemory.scala rename delayedqueue-scala/{jvm => }/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueJDBCConfig.scala (100%) rename delayedqueue-scala/{jvm => }/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueTimeConfig.scala (100%) rename delayedqueue-scala/{jvm => }/src/main/scala/org/funfix/delayedqueue/scala/JdbcConnectionConfig.scala (100%) rename delayedqueue-scala/{jvm => }/src/main/scala/org/funfix/delayedqueue/scala/JdbcDatabasePoolConfig.scala (100%) rename delayedqueue-scala/{jvm => }/src/main/scala/org/funfix/delayedqueue/scala/JdbcDriver.scala (100%) rename delayedqueue-scala/{jvm => }/src/main/scala/org/funfix/delayedqueue/scala/OfferOutcome.scala (100%) rename delayedqueue-scala/{jvm => }/src/main/scala/org/funfix/delayedqueue/scala/RetryConfig.scala (100%) rename delayedqueue-scala/{jvm => }/src/main/scala/org/funfix/delayedqueue/scala/ScheduledMessage.scala (100%) rename delayedqueue-scala/{jvm => }/src/main/scala/org/funfix/delayedqueue/scala/exceptions.scala (100%) rename delayedqueue-scala/{jvm => }/src/main/scala/org/funfix/delayedqueue/scala/hello.scala (100%) rename delayedqueue-scala/{jvm => }/src/test/scala/org/funfix/delayedqueue/scala/CronSpec.scala (100%) rename delayedqueue-scala/{jvm => }/src/test/scala/org/funfix/delayedqueue/scala/DataStructuresPropertySpec.scala (100%) create mode 100644 delayedqueue-scala/src/test/scala/org/funfix/delayedqueue/scala/DelayedQueueInMemorySpec.scala rename delayedqueue-scala/{jvm => }/src/test/scala/org/funfix/delayedqueue/scala/Generators.scala (100%) rename delayedqueue-scala/{jvm => }/src/test/scala/org/funfix/delayedqueue/scala/HelloSuite.scala (100%) rename delayedqueue-scala/{jvm => }/src/test/scala/org/funfix/delayedqueue/scala/JdbcDriverSpec.scala (100%) rename delayedqueue-scala/{jvm => }/src/test/scala/org/funfix/delayedqueue/scala/RetryConfigSpec.scala (100%) rename delayedqueue-scala/{jvm => }/src/test/scala/org/funfix/delayedqueue/scala/ScheduledMessageSpec.scala (100%) diff --git a/build.sbt b/build.sbt index 7f517ab..40590b3 100644 --- a/build.sbt +++ b/build.sbt @@ -64,22 +64,18 @@ lazy val root = project ) .aggregate(delayedqueueJVM) -lazy val delayedqueue = crossProject(JVMPlatform) - .crossType(CrossType.Full) +lazy val delayedqueueJVM = project .in(file("delayedqueue-scala")) .settings( - name := "delayedqueue-scala" - ) - .jvmSettings( + name := "delayedqueue-scala", libraryDependencies ++= Seq( "org.funfix" % "delayedqueue-jvm" % version.value, "org.typelevel" %% "cats-effect" % "3.6.3", // Testing "org.scalameta" %% "munit" % "1.0.4" % Test, "org.typelevel" %% "munit-cats-effect" % "2.1.0" % Test, + "org.typelevel" %% "cats-effect-testkit" % "3.6.3" % Test, "org.scalacheck" %% "scalacheck" % "1.19.0" % Test, "org.scalameta" %% "munit-scalacheck" % "1.2.0" % Test, ) ) - -lazy val delayedqueueJVM = delayedqueue.jvm diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/AckEnvelope.scala b/delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/AckEnvelope.scala similarity index 100% rename from delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/AckEnvelope.scala rename to delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/AckEnvelope.scala diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronConfigHash.scala b/delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/CronConfigHash.scala similarity index 100% rename from delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronConfigHash.scala rename to delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/CronConfigHash.scala diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronDailySchedule.scala b/delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/CronDailySchedule.scala similarity index 100% rename from delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronDailySchedule.scala rename to delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/CronDailySchedule.scala diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronMessage.scala b/delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/CronMessage.scala similarity index 100% rename from delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronMessage.scala rename to delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/CronMessage.scala diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronService.scala b/delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/CronService.scala similarity index 89% rename from delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronService.scala rename to delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/CronService.scala index 09c43f9..9160c38 100644 --- a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/CronService.scala +++ b/delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/CronService.scala @@ -16,8 +16,7 @@ package org.funfix.delayedqueue.scala -import cats.effect.IO -import cats.effect.Resource +import cats.effect.{Resource, IO} import java.time.Duration import java.time.Instant @@ -87,7 +86,7 @@ trait CronService[A] { configHash: CronConfigHash, keyPrefix: String, scheduleInterval: Duration, - generateMany: CronMessageBatchGenerator[A] + generateMany: (Instant) => List[CronMessage[A]] ): Resource[IO, Unit] /** Installs a daily schedule with timezone-aware execution times. @@ -107,7 +106,7 @@ trait CronService[A] { def installDailySchedule( keyPrefix: String, schedule: CronDailySchedule, - generator: CronMessageGenerator[A] + generator: (Instant) => CronMessage[A] ): Resource[IO, Unit] /** Installs a periodic tick that generates messages at fixed intervals. @@ -127,20 +126,6 @@ trait CronService[A] { def installPeriodicTick( keyPrefix: String, period: Duration, - generator: CronPayloadGenerator[A] + generator: (Instant) => A ): Resource[IO, Unit] } - -/** Generates a batch of cron messages based on the current instant. */ -trait CronMessageBatchGenerator[A] { - - /** Creates a batch of cron messages. */ - def apply(now: Instant): List[CronMessage[A]] -} - -/** Generates a payload for a given instant. */ -trait CronPayloadGenerator[A] { - - /** Creates a payload for the given instant. */ - def apply(at: Instant): A -} diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueue.scala b/delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueue.scala similarity index 99% rename from delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueue.scala rename to delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueue.scala index 05ee1de..1257bac 100644 --- a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueue.scala +++ b/delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueue.scala @@ -119,5 +119,5 @@ trait DelayedQueue[A] { def dropAllMessages(confirm: String): IO[Int] /** Utilities for installing cron-like schedules. */ - def getCron: IO[CronService[A]] + def cron: IO[CronService[A]] } diff --git a/delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueInMemory.scala b/delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueInMemory.scala new file mode 100644 index 0000000..87f46c3 --- /dev/null +++ b/delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueInMemory.scala @@ -0,0 +1,239 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import cats.effect.{IO, Resource, Clock} +import cats.syntax.functor.* +import java.time.{Clock as JavaClock, Instant} +import org.funfix.delayedqueue.jvm +import org.funfix.delayedqueue.scala.AckEnvelope.asScala +import org.funfix.delayedqueue.scala.OfferOutcome.asScala +import org.funfix.delayedqueue.scala.BatchedReply.asScala +import org.funfix.delayedqueue.scala.DelayedQueueTimeConfig.asScala +import scala.jdk.CollectionConverters.* +import cats.effect.std.Dispatcher + +/** In-memory implementation of [[DelayedQueue]] using concurrent data structures. + * + * This implementation wraps the JVM [[org.funfix.delayedqueue.jvm.DelayedQueueInMemory]] and + * provides an idiomatic Scala API with Cats Effect IO for managing side effects. + * + * ==Example== + * + * {{{ + * import cats.effect.IO + * import java.time.Instant + * + * def worker(queue: DelayedQueue[IO, String]): IO[Unit] = { + * val process1 = for { + * envelope <- queue.poll + * _ <- logger.info("Received: " + envelope.payload) + * _ <- envelope.acknowledge + * } yield () + * + * process1.attempt + * .onErrorHandleWith { error => + * logger.error("Error processing message, will reprocess after timeout", error) + * } + * .flatMap { _ => + * worker(queue) // Continue processing the next message + * } + * } + * + * DelayedQueueInMemory[IO, String]().use { queue => + * worker(queue).background.use { _ => + * // Push one message after 10 seconds + * queue.offerOrUpdate("key1", "Hello", Instant.now().plusSeconds(10)) + * } + * } + * }}} + * + * @tparam A + * the type of message payloads + */ +object DelayedQueueInMemory { + + /** Creates an in-memory delayed queue with default configuration. + * + * @tparam A + * the type of message payloads + * @param timeConfig + * time configuration (defaults to [[DelayedQueueTimeConfig.DEFAULT_IN_MEMORY]]) + * @param ackEnvSource + * source identifier for envelopes (defaults to "delayed-queue-inmemory") + * @param clock + * clock for time operations (defaults to system UTC clock) + * @return + * a new DelayedQueue instance + */ + def apply[A]( + timeConfig: DelayedQueueTimeConfig = DelayedQueueTimeConfig.DEFAULT_IN_MEMORY, + ackEnvSource: String = "delayed-queue-inmemory" + ): Resource[IO, DelayedQueue[A]] = + Dispatcher.sequential[IO].evalMap { dispatcher => + IO { + val javaClock = CatsClockToJavaClock(dispatcher) + val jvmQueue = jvm.DelayedQueueInMemory.create[A]( + timeConfig.asJava, + ackEnvSource, + javaClock + ) + new DelayedQueueInMemoryWrapper(jvmQueue) + } + } + + /** Wrapper that implements the Scala DelayedQueue trait by delegating to the JVM implementation. + */ + private class DelayedQueueInMemoryWrapper[A]( + underlying: jvm.DelayedQueueInMemory[A] + ) extends DelayedQueue[A] { + + override def getTimeConfig: IO[DelayedQueueTimeConfig] = + IO(underlying.getTimeConfig.asScala) + + override def offerOrUpdate(key: String, payload: A, scheduleAt: Instant): IO[OfferOutcome] = + IO(underlying.offerOrUpdate(key, payload, scheduleAt).asScala) + + override def offerIfNotExists(key: String, payload: A, scheduleAt: Instant): IO[OfferOutcome] = + IO(underlying.offerIfNotExists(key, payload, scheduleAt).asScala) + + override def offerBatch[In]( + messages: List[BatchedMessage[In, A]] + ): IO[List[BatchedReply[In, A]]] = + IO { + val javaMessages = messages.map(_.asJava).asJava + val javaReplies = underlying.offerBatch(javaMessages) + javaReplies.asScala.toList.map(_.asScala) + } + + override def tryPoll: IO[Option[AckEnvelope[A]]] = + IO { + Option(underlying.tryPoll()).map(_.asScala) + } + + override def tryPollMany(batchMaxSize: Int): IO[AckEnvelope[List[A]]] = + IO { + val javaEnvelope = underlying.tryPollMany(batchMaxSize) + AckEnvelope( + payload = javaEnvelope.payload.asScala.toList, + messageId = MessageId.asScala(javaEnvelope.messageId), + timestamp = javaEnvelope.timestamp, + source = javaEnvelope.source, + deliveryType = DeliveryType.asScala(javaEnvelope.deliveryType), + acknowledge = IO.blocking(javaEnvelope.acknowledge()) + ) + } + + override def poll: IO[AckEnvelope[A]] = + IO.interruptible(underlying.poll().asScala) + + override def read(key: String): IO[Option[AckEnvelope[A]]] = + IO { + Option(underlying.read(key)).map(_.asScala) + } + + override def dropMessage(key: String): IO[Boolean] = + IO(underlying.dropMessage(key)) + + override def containsMessage(key: String): IO[Boolean] = + IO(underlying.containsMessage(key)) + + override def dropAllMessages(confirm: String): IO[Int] = + IO(underlying.dropAllMessages(confirm)) + + override def cron: IO[CronService[A]] = + IO(new CronServiceWrapper(underlying.getCron)) + } + + /** Wrapper for CronService that delegates to the JVM implementation. */ + private class CronServiceWrapper[A]( + underlying: jvm.CronService[A] + ) extends CronService[A] { + + override def installTick( + configHash: CronConfigHash, + keyPrefix: String, + messages: List[CronMessage[A]] + ): IO[Unit] = + IO { + val javaMessages = messages.map(_.asJava).asJava + underlying.installTick(configHash.asJava, keyPrefix, javaMessages) + } + + override def uninstallTick(configHash: CronConfigHash, keyPrefix: String): IO[Unit] = + IO { + underlying.uninstallTick(configHash.asJava, keyPrefix) + } + + override def install( + configHash: CronConfigHash, + keyPrefix: String, + scheduleInterval: java.time.Duration, + generateMany: (Instant) => List[CronMessage[A]] + ): Resource[IO, Unit] = + Resource.fromAutoCloseable(IO { + underlying.install( + configHash.asJava, + keyPrefix, + scheduleInterval, + now => generateMany(now).map(_.asJava).asJava + ) + }).void + + override def installDailySchedule( + keyPrefix: String, + schedule: CronDailySchedule, + generator: (Instant) => CronMessage[A] + ): Resource[IO, Unit] = + Resource.fromAutoCloseable(IO { + underlying.installDailySchedule( + keyPrefix, + schedule.asJava, + now => generator(now).asJava + ) + }).void + + override def installPeriodicTick( + keyPrefix: String, + period: java.time.Duration, + generator: (Instant) => A + ): Resource[IO, Unit] = + Resource.fromAutoCloseable(IO { + underlying.installPeriodicTick( + keyPrefix, + period, + now => generator(now) + ) + }).void + } +} + +private final class CatsClockToJavaClock( + dispatcher: Dispatcher[IO], + zone: java.time.ZoneId = java.time.ZoneId.systemDefault() +)(using Clock[IO]) extends JavaClock { + override def getZone: java.time.ZoneId = + zone + + override def withZone(zone: java.time.ZoneId): JavaClock = + new CatsClockToJavaClock(dispatcher, zone) + + override def instant(): Instant = + dispatcher.unsafeRunSync( + Clock[IO].realTime.map(it => Instant.ofEpochMilli(it.toMillis)) + ) +} diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueJDBCConfig.scala b/delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueJDBCConfig.scala similarity index 100% rename from delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueJDBCConfig.scala rename to delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueJDBCConfig.scala diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueTimeConfig.scala b/delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueTimeConfig.scala similarity index 100% rename from delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueTimeConfig.scala rename to delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/DelayedQueueTimeConfig.scala diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/JdbcConnectionConfig.scala b/delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/JdbcConnectionConfig.scala similarity index 100% rename from delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/JdbcConnectionConfig.scala rename to delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/JdbcConnectionConfig.scala diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/JdbcDatabasePoolConfig.scala b/delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/JdbcDatabasePoolConfig.scala similarity index 100% rename from delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/JdbcDatabasePoolConfig.scala rename to delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/JdbcDatabasePoolConfig.scala diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/JdbcDriver.scala b/delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/JdbcDriver.scala similarity index 100% rename from delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/JdbcDriver.scala rename to delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/JdbcDriver.scala diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/OfferOutcome.scala b/delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/OfferOutcome.scala similarity index 100% rename from delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/OfferOutcome.scala rename to delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/OfferOutcome.scala diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/RetryConfig.scala b/delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/RetryConfig.scala similarity index 100% rename from delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/RetryConfig.scala rename to delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/RetryConfig.scala diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/ScheduledMessage.scala b/delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/ScheduledMessage.scala similarity index 100% rename from delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/ScheduledMessage.scala rename to delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/ScheduledMessage.scala diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/exceptions.scala b/delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/exceptions.scala similarity index 100% rename from delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/exceptions.scala rename to delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/exceptions.scala diff --git a/delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/hello.scala b/delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/hello.scala similarity index 100% rename from delayedqueue-scala/jvm/src/main/scala/org/funfix/delayedqueue/scala/hello.scala rename to delayedqueue-scala/src/main/scala/org/funfix/delayedqueue/scala/hello.scala diff --git a/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/CronSpec.scala b/delayedqueue-scala/src/test/scala/org/funfix/delayedqueue/scala/CronSpec.scala similarity index 100% rename from delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/CronSpec.scala rename to delayedqueue-scala/src/test/scala/org/funfix/delayedqueue/scala/CronSpec.scala diff --git a/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/DataStructuresPropertySpec.scala b/delayedqueue-scala/src/test/scala/org/funfix/delayedqueue/scala/DataStructuresPropertySpec.scala similarity index 100% rename from delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/DataStructuresPropertySpec.scala rename to delayedqueue-scala/src/test/scala/org/funfix/delayedqueue/scala/DataStructuresPropertySpec.scala diff --git a/delayedqueue-scala/src/test/scala/org/funfix/delayedqueue/scala/DelayedQueueInMemorySpec.scala b/delayedqueue-scala/src/test/scala/org/funfix/delayedqueue/scala/DelayedQueueInMemorySpec.scala new file mode 100644 index 0000000..ddaac1d --- /dev/null +++ b/delayedqueue-scala/src/test/scala/org/funfix/delayedqueue/scala/DelayedQueueInMemorySpec.scala @@ -0,0 +1,356 @@ +/* + * Copyright 2026 Alexandru Nedelcu + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.funfix.delayedqueue.scala + +import cats.syntax.all.* +import cats.effect.IO +import java.time.Instant +import munit.CatsEffectSuite +import scala.concurrent.duration.* + +class DelayedQueueInMemorySpec extends CatsEffectSuite { + + test("apply should return a working queue") { + DelayedQueueInMemory[String]().use { queue => + queue.getTimeConfig.assertEquals(DelayedQueueTimeConfig.DEFAULT_IN_MEMORY) + } + } + + test("offerOrUpdate should create a new message") { + DelayedQueueInMemory[String]().use { queue => + for { + scheduleAt <- IO(Instant.now().plusSeconds(10)) + result <- queue.offerOrUpdate("key1", "payload1", scheduleAt) + } yield assertEquals(result, OfferOutcome.Created) + } + } + + test("offerOrUpdate should update an existing message") { + DelayedQueueInMemory[String]().use { queue => + for { + scheduleAt <- IO(Instant.now().plusSeconds(10)) + _ <- queue.offerOrUpdate("key1", "payload1", scheduleAt) + result <- queue.offerOrUpdate("key1", "payload2", scheduleAt.plusSeconds(5)) + } yield assertEquals(result, OfferOutcome.Updated) + } + } + + test("offerIfNotExists should create a new message") { + DelayedQueueInMemory[String]().use { queue => + for { + scheduleAt <- IO(Instant.now().plusSeconds(10)) + result <- queue.offerIfNotExists("key1", "payload1", scheduleAt) + } yield assertEquals(result, OfferOutcome.Created) + } + } + + test("offerIfNotExists should ignore existing message") { + DelayedQueueInMemory[String]().use { queue => + for { + scheduleAt <- IO(Instant.now().plusSeconds(10)) + _ <- queue.offerIfNotExists("key1", "payload1", scheduleAt) + result <- queue.offerIfNotExists("key1", "payload2", scheduleAt.plusSeconds(5)) + } yield assertEquals(result, OfferOutcome.Ignored) + } + } + + test("tryPoll should return None when no messages are available") { + DelayedQueueInMemory[String]().use { queue => + queue.tryPoll.assertEquals(None) + } + } + + test("tryPoll should return a message when scheduled time has passed") { + DelayedQueueInMemory[String]().use { queue => + for { + scheduleAt <- IO(Instant.now().minusSeconds(1)) + _ <- queue.offerOrUpdate("key1", "payload1", scheduleAt) + envelope <- queue.tryPoll + _ <- IO { + assert(envelope.isDefined) + assertEquals(envelope.get.payload, "payload1") + assertEquals(envelope.get.messageId.value, "key1") + } + } yield () + } + } + + test("tryPollMany should return empty list when no messages are available") { + DelayedQueueInMemory[String]().use { queue => + queue.tryPollMany(10).map { envelope => + assertEquals(envelope.payload, List.empty[String]) + } + } + } + + test("tryPollMany should return multiple messages") { + DelayedQueueInMemory[String]().use { queue => + for { + scheduleAt <- IO(Instant.now().minusSeconds(1)) + _ <- queue.offerOrUpdate("key1", "payload1", scheduleAt) + _ <- queue.offerOrUpdate("key2", "payload2", scheduleAt) + _ <- queue.offerOrUpdate("key3", "payload3", scheduleAt) + envelope <- queue.tryPollMany(5) + _ <- IO { + assertEquals(envelope.payload.length, 3) + assertEquals( + envelope.payload.toSet, + Set("payload1", "payload2", "payload3"), + "tryPollMany should return all three messages" + ) + } + } yield () + } + } + + test("offerBatch should handle multiple messages") { + DelayedQueueInMemory[String]().use { queue => + for { + scheduleAt <- IO(Instant.now().plusSeconds(10)) + messages = List( + BatchedMessage( + "input1", + ScheduledMessage("key1", "payload1", scheduleAt, canUpdate = true) + ), + BatchedMessage( + "input2", + ScheduledMessage("key2", "payload2", scheduleAt, canUpdate = true) + ) + ) + replies <- queue.offerBatch(messages) + _ <- IO { + assertEquals(replies.length, 2) + assertEquals(replies(0).outcome, OfferOutcome.Created) + assertEquals(replies(1).outcome, OfferOutcome.Created) + } + } yield () + } + } + + test("read should return a message without locking it") { + DelayedQueueInMemory[String]().use { queue => + for { + scheduleAt <- IO(Instant.now().plusSeconds(10)) + _ <- queue.offerOrUpdate("key1", "payload1", scheduleAt) + envelope <- queue.read("key1") + stillExists <- queue.containsMessage("key1") + _ <- IO { + assert(envelope.isDefined, "envelope should be defined") + assertEquals(envelope.get.payload, "payload1") + assert(stillExists, "message should still exist after read") + } + } yield () + } + } + + test("dropMessage should remove a message") { + DelayedQueueInMemory[String]().use { queue => + for { + scheduleAt <- IO(Instant.now().plusSeconds(10)) + _ <- queue.offerOrUpdate("key1", "payload1", scheduleAt) + dropped <- queue.dropMessage("key1") + exists <- queue.containsMessage("key1") + _ <- IO { + assert(dropped, "dropMessage should return true") + assert(!exists, "message should not exist after drop") + } + } yield () + } + } + + test("containsMessage should return true for existing message") { + DelayedQueueInMemory[String]().use { queue => + for { + scheduleAt <- IO(Instant.now().plusSeconds(10)) + _ <- queue.offerOrUpdate("key1", "payload1", scheduleAt) + exists <- queue.containsMessage("key1") + _ <- IO(assert(exists, "message should exist")) + } yield () + } + } + + test("containsMessage should return false for non-existing message") { + DelayedQueueInMemory[String]().use { queue => + queue.containsMessage("nonexistent").map { exists => + assert(!exists, "nonexistent message should not exist") + } + } + } + + test("dropAllMessages should remove all messages") { + DelayedQueueInMemory[String]().use { queue => + for { + scheduleAt <- IO(Instant.now().plusSeconds(10)) + _ <- queue.offerOrUpdate("key1", "payload1", scheduleAt) + _ <- queue.offerOrUpdate("key2", "payload2", scheduleAt) + count <- queue.dropAllMessages("Yes, please, I know what I'm doing!") + exists1 <- queue.containsMessage("key1") + exists2 <- queue.containsMessage("key2") + _ <- IO { + assertEquals(count, 2) + assert(!exists1, "key1 should not exist after dropAll") + assert(!exists2, "key2 should not exist after dropAll") + } + } yield () + } + } + + test("acknowledge should delete the message") { + DelayedQueueInMemory[String]().use { queue => + for { + scheduleAt <- IO(Instant.now().minusSeconds(1)) + _ <- queue.offerOrUpdate("key1", "payload1", scheduleAt) + envelope <- queue.tryPoll + _ <- { + assert(envelope.isDefined, "envelope should be defined") + envelope.get.acknowledge + } + exists <- queue.containsMessage("key1") + _ <- IO(assert(!exists, "message should be deleted after acknowledgment")) + } yield () + } + } + + test("cron should return a CronService") { + DelayedQueueInMemory[String]().use { queue => + queue.cron.map { cronService => + assert(cronService != null, "cronService should not be null") + } + } + } + + test("custom timeConfig should be used") { + val customConfig = DelayedQueueTimeConfig( + acquireTimeout = 60.seconds, + pollPeriod = 200.milliseconds + ) + DelayedQueueInMemory[String](timeConfig = customConfig).use { queue => + queue.getTimeConfig.assertEquals(customConfig) + } + } + + test("custom ackEnvSource should be used") { + DelayedQueueInMemory[String](ackEnvSource = "custom-source").use { queue => + for { + scheduleAt <- IO(Instant.now().minusSeconds(1)) + _ <- queue.offerOrUpdate("key1", "payload1", scheduleAt) + envelope <- queue.tryPoll + _ <- IO { + assert(envelope.isDefined, "envelope should be defined") + assertEquals(envelope.get.source, "custom-source") + } + } yield () + } + } + + test("time passage: offer in future, tryPoll returns None, advance time, tryPoll succeeds") { + DelayedQueueInMemory[String]().use { queue => + for { + now <- IO.realTime.map(d => Instant.ofEpochMilli(d.toMillis)) + futureTime = now.plusMillis(100) // 100ms in the future + pastTime = now.minusMillis(100) // 100ms in the past + + // Offer a message scheduled for the future + _ <- queue.offerOrUpdate("key1", "payload1", futureTime) + + // Try to poll immediately - should get None (not scheduled yet) + resultBefore <- queue.tryPoll + + // Offer a message scheduled for the past + _ <- queue.offerOrUpdate("key2", "payload2", pastTime) + + // Now tryPoll should succeed on the past-scheduled message + resultAfter <- queue.tryPoll + + _ <- IO { + assertEquals(resultBefore, None, "tryPoll should return None before scheduled time") + assert(resultAfter.isDefined, "tryPoll should return Some for past-scheduled message") + assertEquals(resultAfter.get.payload, "payload2") + assertEquals(resultAfter.get.messageId.value, "key2") + } + } yield () + } + } + + test("concurrency") { + val producers = 4 + val consumers = 4 + val messageCount = 10000 + val now = Instant.now() + + assert(messageCount % producers == 0, "messageCount should be divisible by number of producers") + assert(messageCount % consumers == 0, "messageCount should be divisible by number of producers") + + def producer(queue: DelayedQueue[String], id: Int, count: Int): IO[Int] = + (1 to count).toList.traverse { i => + val key = s"producer-$id-message-$i" + val payload = s"payload-$key" + queue.offerOrUpdate(key, payload, now).map { outcome => + assertEquals(outcome, OfferOutcome.Created) + 1 + } + }.map { + _.sum + } + + def allProducers(queue: DelayedQueue[String]): IO[Int] = (1 to producers).toList.parTraverse { + id => + producer(queue, id, messageCount / producers) + }.map { + _.sum + } + + def consumer(queue: DelayedQueue[String], count: Int): IO[Int] = (1 to count).toList.traverse { + _ => + queue.poll.map { envelope => + assert( + envelope.payload.startsWith("payload-"), + "payload should have correct format" + ) + 1 + } + }.map { + _.sum + } + + def allConsumers(queue: DelayedQueue[String]): IO[Int] = (1 to consumers).toList.parTraverse { + _ => + consumer(queue, messageCount / consumers) + }.map { + _.sum + } + + val res = + for { + queue <- DelayedQueueInMemory[String]() + prodFiber <- allProducers(queue).background + conFiber <- allConsumers(queue).background + } yield (prodFiber, conFiber) + + res.use { case (prodFiber, conFiber) => + for { + p <- prodFiber + p <- p.embedNever + c <- conFiber + c <- c.embedNever + } yield { + assertEquals(p, messageCount) + assertEquals(c, messageCount) + } + }.timeout(30.seconds) + } +} diff --git a/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/Generators.scala b/delayedqueue-scala/src/test/scala/org/funfix/delayedqueue/scala/Generators.scala similarity index 100% rename from delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/Generators.scala rename to delayedqueue-scala/src/test/scala/org/funfix/delayedqueue/scala/Generators.scala diff --git a/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/HelloSuite.scala b/delayedqueue-scala/src/test/scala/org/funfix/delayedqueue/scala/HelloSuite.scala similarity index 100% rename from delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/HelloSuite.scala rename to delayedqueue-scala/src/test/scala/org/funfix/delayedqueue/scala/HelloSuite.scala diff --git a/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/JdbcDriverSpec.scala b/delayedqueue-scala/src/test/scala/org/funfix/delayedqueue/scala/JdbcDriverSpec.scala similarity index 100% rename from delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/JdbcDriverSpec.scala rename to delayedqueue-scala/src/test/scala/org/funfix/delayedqueue/scala/JdbcDriverSpec.scala diff --git a/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/RetryConfigSpec.scala b/delayedqueue-scala/src/test/scala/org/funfix/delayedqueue/scala/RetryConfigSpec.scala similarity index 100% rename from delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/RetryConfigSpec.scala rename to delayedqueue-scala/src/test/scala/org/funfix/delayedqueue/scala/RetryConfigSpec.scala diff --git a/delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/ScheduledMessageSpec.scala b/delayedqueue-scala/src/test/scala/org/funfix/delayedqueue/scala/ScheduledMessageSpec.scala similarity index 100% rename from delayedqueue-scala/jvm/src/test/scala/org/funfix/delayedqueue/scala/ScheduledMessageSpec.scala rename to delayedqueue-scala/src/test/scala/org/funfix/delayedqueue/scala/ScheduledMessageSpec.scala diff --git a/project/plugins.sbt b/project/plugins.sbt index da89c90..e612a01 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -1,7 +1,3 @@ -addSbtPlugin("org.portable-scala" % "sbt-scala-native-crossproject" % "1.3.2") -addSbtPlugin("org.portable-scala" % "sbt-scalajs-crossproject" % "1.3.2") -addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.20.2") -addSbtPlugin("org.scala-native" % "sbt-scala-native" % "0.5.10") addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.6") addSbtPlugin("org.typelevel" % "sbt-tpolecat" % "0.5.2")