From b5b35c5a710470aad52b309dbc462cade37f330f Mon Sep 17 00:00:00 2001 From: harryzhuang Date: Thu, 30 Dec 2021 15:30:32 +0800 Subject: [PATCH 1/3] support redis zset --- .../scala/play/api/cache/redis/CacheApi.scala | 9 ++ .../play/api/cache/redis/RedisSortedSet.scala | 51 +++++++++++ .../redis/connector/RedisConnector.scala | 90 +++++++++++++++++++ .../redis/connector/RedisConnectorImpl.scala | 51 +++++++++++ .../api/cache/redis/impl/RedisCache.scala | 4 + .../cache/redis/impl/RedisSortedSetImpl.scala | 48 ++++++++++ version.sbt | 2 +- 7 files changed, 254 insertions(+), 1 deletion(-) create mode 100644 src/main/scala/play/api/cache/redis/RedisSortedSet.scala create mode 100644 src/main/scala/play/api/cache/redis/impl/RedisSortedSetImpl.scala diff --git a/src/main/scala/play/api/cache/redis/CacheApi.scala b/src/main/scala/play/api/cache/redis/CacheApi.scala index 906f090a..ba303080 100644 --- a/src/main/scala/play/api/cache/redis/CacheApi.scala +++ b/src/main/scala/play/api/cache/redis/CacheApi.scala @@ -280,6 +280,15 @@ private[redis] trait AbstractCacheApi[Result[_]] { * @return Scala wrapper */ def map[T: ClassTag](key: String): RedisMap[T, Result] + + /** + * Scala wrapper around Redis sorted-set-related commands. + * + * @param key the key storing the map + * @tparam T type of elements within the sorted-set + * @return Scala wrapper + */ + def zset[T: ClassTag](key: String): RedisSortedSet[T, Result] } /** Synchronous and blocking implementation of the connection to the redis database */ diff --git a/src/main/scala/play/api/cache/redis/RedisSortedSet.scala b/src/main/scala/play/api/cache/redis/RedisSortedSet.scala new file mode 100644 index 00000000..8c9df70e --- /dev/null +++ b/src/main/scala/play/api/cache/redis/RedisSortedSet.scala @@ -0,0 +1,51 @@ +package play.api.cache.redis + +import scala.collection.immutable.TreeSet + +trait RedisSortedSet[Elem, Result[_]] extends RedisCollection[TreeSet[Elem], Result] { + override type This = RedisSortedSet[Elem, Result] + + /** + * Adds all the specified members with the specified scores to the sorted set stored at key. + * It is possible to specify multiple score / member pairs. If a specified member is already + * a member of the sorted set, the score is updated and the element reinserted at the right + * position to ensure the correct ordering. + * + * If key does not exist, a new sorted set with the specified members as sole members is created, + * like if the sorted set was empty. + * + * @note If the key exists but does not hold a sorted set, an error is returned. + * @note Time complexity: O(log(N)) for each item added, where N is the number of elements in the sorted set. + * @param scoreValues values and corresponding scores to be added + * @return the sorted set for chaining calls + */ + def add(scoreValues: (Double, Elem)*): Result[This] + + /** + *

Tests if the element is contained in the sorted set. Returns true if exists, otherwise returns false

+ * + * @note Time complexity: O(1) + * @param element tested element + * @return true if exists in the set, otherwise false + */ + def contains(element: Elem): Result[Boolean] + + /** + *

Removes the specified members from the sorted set stored at key. Non existing members are ignored. + * An error is returned when key exists and does not hold a sorted set.

+ * + * @note Time complexity: O(M*log(N)) with N being the number of elements in the sorted set and M the number of elements to be removed. + * @param element elements to be removed + * @return the sorted set for chaining calls + */ + def remove(element: Elem*): Result[This] + + /** + * Returns the specified range of elements in the sorted set stored at key which sorted in order specified by param `isReverse`. + * @param start the start index of the range + * @param stop the stop index of the range + * @param isReverse whether sorted in descending order or not + * @return + */ + def range(start: Long, stop: Long, isReverse: Boolean = false): Result[Seq[Elem]] +} diff --git a/src/main/scala/play/api/cache/redis/connector/RedisConnector.scala b/src/main/scala/play/api/cache/redis/connector/RedisConnector.scala index eb46e5fb..3e49fcd5 100644 --- a/src/main/scala/play/api/cache/redis/connector/RedisConnector.scala +++ b/src/main/scala/play/api/cache/redis/connector/RedisConnector.scala @@ -463,6 +463,95 @@ private[redis] trait SetCommands { def setRemove(key: String, value: Any*): Future[Long] } +/** + * Internal non-blocking Redis API implementing REDIS protocol + * + * Subset of REDIS commands, sorted set related commands. + * + * @see https://redis.io/commands + */ +private[redis] trait SortedSetCommands { + + /** + * Adds all the specified members with the specified scores to the sorted set stored at key. + * It is possible to specify multiple score / member pairs. If a specified member is already + * a member of the sorted set, the score is updated and the element reinserted at the right + * position to ensure the correct ordering. + * + * If key does not exist, a new sorted set with the specified members as sole members is created, + * like if the sorted set was empty. If the key exists but does not hold a sorted set, an error + * is returned. + * + * @note Time complexity: O(log(N)) for each item added, where N is the number of elements in the sorted set. + * @param key cache storage key + * @param scoreValues values and corresponding scores to be added + * @return number of inserted elements ignoring already existing + */ + def zsetAdd(key: String, scoreValues: (Double, Any)*): Future[Long] + + /** + * Returns the sorted set cardinality (number of elements) of the sorted set stored at key. + * + * Time complexity: O(1) + * + * @param key cache storage key + * @return the cardinality (number of elements) of the set, or 0 if key does not exist. + */ + def zsetSize(key: String): Future[Long] + + /** + * Returns the score of member in the sorted set at key. + * + * If member does not exist in the sorted set, or key does not exist, nil is returned. + * + * Time complexity: O(1) + * + * @param key cache storage key + * @param value tested element + * @return the score of member (a double precision floating point number). + */ + def zscore(key: String, value: Any): Future[Option[Double]] + + /** + * Removes the specified members from the sorted set stored at key. Non existing members are ignored. + * + * An error is returned when key exists and does not hold a sorted set. + * + * Time complexity: O(M*log(N)) with N being the number of elements in the sorted set and M the number of elements to be removed. + * + * @param key cache storage key + * @param value values to be removed + * @return total number of removed values, non existing are ignored + */ + def zsetRemove(key: String, value: Any*): Future[Long] + + /** + * Returns the specified range of elements in the sorted set stored at key. + * + * An error is returned when key exists and does not hold a sorted set. + * @param key cache storage key + * @param start the start index of the range + * @param stop the stop index of the range + * @note The start and stop arguments represent zero-based indexes, where 0 is the first element, + * 1 is the next element, and so on. These arguments specify an inclusive range. + * @return list of elements in the specified range + */ + def zrange[T: ClassTag](key: String, start: Long, stop: Long): Future[Seq[T]] + + /** + * Returns the specified range of elements in the sorted set stored at key. + * The elements are considered to be ordered from the highest to the lowest score. + * Descending lexicographical order is used for elements with equal score. + * + * @param key cache storage key + * @param start the start index of the range + * @param stop the stop index of the range + * @note Apart from the reversed ordering, the zrevRange is similar to zrange. + * @return list of elements in the specified range + */ + def zrevRange[T: ClassTag](key: String, start: Long, stop: Long): Future[Seq[T]] +} + /** * Internal non-blocking Redis API implementing REDIS protocol * @@ -473,3 +562,4 @@ trait RedisConnector extends AnyRef with ListCommands with SetCommands with HashCommands + with SortedSetCommands diff --git a/src/main/scala/play/api/cache/redis/connector/RedisConnectorImpl.scala b/src/main/scala/play/api/cache/redis/connector/RedisConnectorImpl.scala index 99b91cc5..2e58d755 100644 --- a/src/main/scala/play/api/cache/redis/connector/RedisConnectorImpl.scala +++ b/src/main/scala/play/api/cache/redis/connector/RedisConnectorImpl.scala @@ -294,6 +294,57 @@ private[connector] class RedisConnectorImpl(serializer: AkkaSerializer, redis: R } } + def zsetAdd(key: String, scoreValues: (Double, Any)*) = { + // encodes the value + def toEncoded(value: Any) = encode(key, value) + Future.sequence(scoreValues.map(scoreValue => toEncoded(scoreValue._2).map(encodedString => (scoreValue._1, encodedString)))).flatMap(redis.zadd(key, _: _*)) executing "ZADD" withKey key andParameters scoreValues expects { + case inserted => + log.debug(s"Inserted $inserted elements into the zset at '$key'.") + inserted + } recover { + case ExecutionFailedException(_, _, _, ex) if ex.getMessage startsWith "WRONGTYPE" => + log.warn(s"Value at '$key' is not a zset.") + throw new IllegalArgumentException(s"Value at '$key' is not a zset.") + } + } + + def zsetSize(key: String) = + redis.zcard(key) executing "ZCARD" withKey key logging { + case length => log.debug(s"The zset at '$key' has $length items.") + } + + def zscore(key: String, value: Any) = { + encode(key, value) flatMap (redis.zscore(key, _)) executing "ZSCORE" withKey key andParameter value logging { + case Some(score) => log.debug(s"The score of item: $value is $score in the collection at '$key'.") + case None => log.debug(s"Item $value does not exist in the collection at '$key'") + } + } + + def zsetRemove(key: String, values: Any*) = { + // encodes the value + def toEncoded(value: Any) = encode(key, value) + + Future.sequence(values map toEncoded).flatMap(redis.zrem(key, _: _*)) executing "ZREM" withKey key andParameters values logging { + case removed => log.debug(s"Removed $removed elements from the zset at '$key'.") + } + } + + def zrange[T: ClassTag](key: String, start: Long, stop: Long) = { + redis.zrange[String](key, start, stop) executing "ZRANGE" withKey key andParameter s"$start $stop" expects { + case encodedSeq => + log.debug(s"Got range from $start to $stop in the zset at '$key'.") + encodedSeq.map(encoded => decode[T](key, encoded)) + } + } + + def zrevRange[T: ClassTag](key: String, start: Long, stop: Long) = { + redis.zrevrange[String](key, start, stop) executing "ZREVRANGE" withKey key andParameter s"$start $stop" expects { + case encodedSeq => + log.debug(s"Got reverse range from $start to $stop in the zset at '$key'.") + encodedSeq.map(encoded => decode[T](key, encoded)) + } + } + def hashRemove(key: String, fields: String*) = redis.hdel(key, fields: _*) executing "HDEL" withKey key andParameters fields logging { case removed => log.debug(s"Removed $removed elements from the collection at '$key'.") diff --git a/src/main/scala/play/api/cache/redis/impl/RedisCache.scala b/src/main/scala/play/api/cache/redis/impl/RedisCache.scala index 3672dcf5..ce8e7caa 100644 --- a/src/main/scala/play/api/cache/redis/impl/RedisCache.scala +++ b/src/main/scala/play/api/cache/redis/impl/RedisCache.scala @@ -126,6 +126,10 @@ private[impl] class RedisCache[Result[_]](redis: RedisConnector, builder: Builde new RedisMapImpl(key, redis) } + def zset[T: ClassTag](key: String): RedisSortedSet[T, Result] = key.prefixed { key => + new RedisSortedSetImpl(key, redis) + } + // $COVERAGE-OFF$ override def toString = s"RedisCache(name=${runtime.name})" // $COVERAGE-ON$ diff --git a/src/main/scala/play/api/cache/redis/impl/RedisSortedSetImpl.scala b/src/main/scala/play/api/cache/redis/impl/RedisSortedSetImpl.scala new file mode 100644 index 00000000..cdf05d11 --- /dev/null +++ b/src/main/scala/play/api/cache/redis/impl/RedisSortedSetImpl.scala @@ -0,0 +1,48 @@ +package play.api.cache.redis.impl + +import play.api.cache.redis._ + +import scala.language.{higherKinds, implicitConversions} +import scala.reflect.ClassTag + +/**

Implementation of Set API using redis-server cache implementation.

*/ +private[impl] class RedisSortedSetImpl[Elem: ClassTag, Result[_]](key: String, redis: RedisConnector)(implicit builder: Builders.ResultBuilder[Result], runtime: RedisRuntime) extends RedisSortedSet[Elem, Result] { + + // implicit ask timeout and execution context + import dsl._ + + @inline + private def This: This = this + + def add(scoreValues: (Double, Elem)*) = { + redis.zsetAdd(key, scoreValues: _*).map(_ => This).recoverWithDefault(This) + } + + def contains(element: Elem) = { + redis.zscore(key, element).map(_.isDefined).recoverWithDefault(false) + } + + def remove(element: Elem*) = { + redis.zsetRemove(key, element: _*).map(_ => This).recoverWithDefault(This) + } + + def range(start: Long, stop: Long, isReverse: Boolean = false) = { + if (isReverse) { + redis.zrevRange[Elem](key, start, stop).recoverWithDefault(Seq.empty) + } else { + redis.zrange[Elem](key, start, stop).recoverWithDefault(Seq.empty) + } + } + + def size = { + redis.zsetSize(key).recoverWithDefault(0) + } + + def isEmpty = { + redis.zsetSize(key).map(_ == 0).recoverWithDefault(true) + } + + def nonEmpty = { + redis.zsetSize(key).map(_ > 0).recoverWithDefault(false) + } +} diff --git a/version.sbt b/version.sbt index f5d2149e..adb1d9f4 100644 --- a/version.sbt +++ b/version.sbt @@ -1 +1 @@ -version in ThisBuild := "2.6.2-SNAPSHOT" +version in ThisBuild := "2.6.3-SNAPSHOT" From 87ec9b93db97b8bc092f048bb0cffe1fcc406eb1 Mon Sep 17 00:00:00 2001 From: harryzhuang Date: Sun, 6 Feb 2022 11:25:10 +0800 Subject: [PATCH 2/3] code optimization --- .../play/api/cache/redis/connector/RedisConnectorImpl.scala | 5 +++-- version.sbt | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/scala/play/api/cache/redis/connector/RedisConnectorImpl.scala b/src/main/scala/play/api/cache/redis/connector/RedisConnectorImpl.scala index 2e58d755..b7236105 100644 --- a/src/main/scala/play/api/cache/redis/connector/RedisConnectorImpl.scala +++ b/src/main/scala/play/api/cache/redis/connector/RedisConnectorImpl.scala @@ -296,8 +296,9 @@ private[connector] class RedisConnectorImpl(serializer: AkkaSerializer, redis: R def zsetAdd(key: String, scoreValues: (Double, Any)*) = { // encodes the value - def toEncoded(value: Any) = encode(key, value) - Future.sequence(scoreValues.map(scoreValue => toEncoded(scoreValue._2).map(encodedString => (scoreValue._1, encodedString)))).flatMap(redis.zadd(key, _: _*)) executing "ZADD" withKey key andParameters scoreValues expects { + def toEncoded(scoreValue: (Double, Any)) = encode(key, scoreValue._2).map((scoreValue._1, _)) + + Future.sequence(scoreValues.map(toEncoded)).flatMap(redis.zadd(key, _: _*)) executing "ZADD" withKey key andParameters scoreValues expects { case inserted => log.debug(s"Inserted $inserted elements into the zset at '$key'.") inserted diff --git a/version.sbt b/version.sbt index adb1d9f4..96955ea9 100644 --- a/version.sbt +++ b/version.sbt @@ -1 +1 @@ -version in ThisBuild := "2.6.3-SNAPSHOT" +version in ThisBuild := "2.7.0-SNAPSHOT" From c0a5fca82c71d9329a63f4f52621bc2b8e23f464 Mon Sep 17 00:00:00 2001 From: harryzhuang Date: Sun, 6 Feb 2022 12:18:31 +0800 Subject: [PATCH 3/3] add zset test case --- .../redis/impl/RedisCacheImplicits.scala | 7 ++ .../cache/redis/impl/RedisSortedSetSpec.scala | 70 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 src/test/scala/play/api/cache/redis/impl/RedisSortedSetSpec.scala diff --git a/src/test/scala/play/api/cache/redis/impl/RedisCacheImplicits.scala b/src/test/scala/play/api/cache/redis/impl/RedisCacheImplicits.scala index 85031ee7..a4953674 100644 --- a/src/test/scala/play/api/cache/redis/impl/RedisCacheImplicits.scala +++ b/src/test/scala/play/api/cache/redis/impl/RedisCacheImplicits.scala @@ -42,6 +42,9 @@ object RedisCacheImplicits { protected val value = "value" protected val other = "other" + protected val scoreValue: (Double, String) = (1.0, "value") + protected val otherScoreValue: (Double, String) = (2.0, "other") + protected def invocation = LazyInvocation protected def policy: RecoveryPolicy = new RecoverWithDefault {} @@ -74,6 +77,10 @@ object RedisCacheImplicits { protected val set = cache.set[String]("key") } + class MockedSortedSet extends MockedCache { + protected val set = cache.zset[String]("key") + } + class MockedMap extends MockedCache { protected val field = "field" diff --git a/src/test/scala/play/api/cache/redis/impl/RedisSortedSetSpec.scala b/src/test/scala/play/api/cache/redis/impl/RedisSortedSetSpec.scala new file mode 100644 index 00000000..4a7c95a7 --- /dev/null +++ b/src/test/scala/play/api/cache/redis/impl/RedisSortedSetSpec.scala @@ -0,0 +1,70 @@ +package play.api.cache.redis.impl + +import org.specs2.concurrent.ExecutionEnv +import org.specs2.mutable.Specification +import play.api.cache.redis._ + +class RedisSortedSetSpec(implicit ee: ExecutionEnv) extends Specification with ReducedMockito { + import Implicits._ + import RedisCacheImplicits._ + import org.mockito.ArgumentMatchers._ + + "Redis Set" should { + + "add" in new MockedSortedSet { + connector.zsetAdd(anyString, anyVarArgs) returns 5L + set.add(scoreValue) must beEqualTo(set).await + set.add(scoreValue, otherScoreValue) must beEqualTo(set).await + there were one(connector).zsetAdd(key, scoreValue) + there were one(connector).zsetAdd(key, scoreValue, otherScoreValue) + } + + "add (failing)" in new MockedSortedSet { + connector.zsetAdd(anyString, anyVarArgs) returns ex + set.add(scoreValue) must beEqualTo(set).await + there were one(connector).zsetAdd(key, scoreValue) + } + + "remove" in new MockedSortedSet { + connector.zsetRemove(anyString, anyVarArgs) returns 1L + set.remove(value) must beEqualTo(set).await + set.remove(other, value) must beEqualTo(set).await + there were one(connector).zsetRemove(key, value) + there were one(connector).zsetRemove(key, other, value) + } + + "remove (failing)" in new MockedSortedSet { + connector.zsetRemove(anyString, anyVarArgs) returns ex + set.remove(value) must beEqualTo(set).await + there were one(connector).zsetRemove(key, value) + } + + "size" in new MockedSortedSet { + connector.zsetSize(key) returns 2L + set.size must beEqualTo(2L).await + } + + "size (failing)" in new MockedSortedSet { + connector.zsetSize(key) returns ex + set.size must beEqualTo(0L).await + } + + "empty set" in new MockedSortedSet { + connector.zsetSize(beEq(key)) returns 0L + set.isEmpty must beTrue.await + set.nonEmpty must beFalse.await + } + + "non-empty set" in new MockedSortedSet { + connector.zsetSize(beEq(key)) returns 1L + set.isEmpty must beFalse.await + set.nonEmpty must beTrue.await + } + + "empty/non-empty set (failing)" in new MockedSortedSet { + connector.zsetSize(beEq(key)) returns ex + set.isEmpty must beTrue.await + set.nonEmpty must beFalse.await + } + } +}