Repository: fppt/jedis-mock
Pull request #493 (2024-07-22 11:21:56)

Bump org.apache.maven.plugins:maven-javadoc-plugin from 3.7.0 to 3.8.0
Bumps [org.apache.maven.plugins:maven-javadoc-plugin](https://github.com/apache/maven-javadoc-plugin) from 3.7.0 to 3.8.0.
<details>
<summary>Commits</summary>
<ul>
<li><a href="https://github.com/apache/maven-javadoc-plugin/commit/621cba66b6aee068a370806b1619cf1d87e9c582"><code>621cba6</code></a> [maven-release-plugin] prepare release maven-javadoc-plugin-3.8.0</li>
<li><a href="https://github.com/apache/maven-javadoc-plugin/commit/eab964ca3f7df4c5bc94ef78a2547729bbcb1c88"><code>eab964c</code></a> [MJAVADOC-603] javadoc:fix failure on JDK10: java.lang.ClassNotFoundException...</li>
<li><a href="https://github.com/apache/maven-javadoc-plugin/commit/0a26a7e048e1b0ddb1951983e33e8f3453e87f17"><code>0a26a7e</code></a> Update since tags</li>
<li><a href="https://github.com/apache/maven-javadoc-plugin/commit/08205b187e976bb7d9266df3b8e7865dae755105"><code>08205b1</code></a> Add compile step for MJAVADOC-365 IT</li>
<li><a href="https://github.com/apache/maven-javadoc-plugin/commit/4c8ca8ede6517cc34a7a1a38ba113d0fac351e4d"><code>4c8ca8e</code></a> [MJAVADOC-804] Remove temporary directories created by tests</li>
<li><a href="https://github.com/apache/maven-javadoc-plugin/commit/91369fabe400ea6c2d91a36d90885d36b447d3ec"><code>91369fa</code></a> [MJAVADOC-775] Option 'taglets/taglet/tagletpath' ignored when pointing to a JAR</li>
<li><a href="https://github.com/apache/maven-javadoc-plugin/commit/be2fa208674c781b08658878ae375f2c058992fc"><code>be2fa20</code></a> [MJAVADOC-783] Invalid path when using TagletArtifact and TagletPath</li>
<li><a href="https://github.com/apache/maven-javadoc-plugin/commit/3eb47c5081bf6f4158333bd378641bc9b7c9a25f"><code>3eb47c5</code></a> [MJAVADOC-791] maven-javadoc-plugin not working correctly together with maven...</li>
<li><a href="https://github.com/apache/maven-javadoc-plugin/commit/d3afd39f63f730f5018e8bb00bbf4226dd0f482f"><code>d3afd39</code></a> [MJAVADOC-803] Add default parameter to force root locale</li>
<li><a href="https://github.com/apache/maven-javadoc-plugin/commit/4904e0825ae265f5d152a9f0d7eebbcc038c55c9"><code>4904e08</code></a> [MJAVADOC-802] Set default value of defaultAuthor parameter in fix goals to $...</li>
<li>Additional commits viewable in <a href="https://github.com/apache/maven-javadoc-plugin/compare/maven-javadoc-plugin-3.7.0...maven-javadoc-plugin-3.8.0">compare view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=org.apache.maven.plugins:maven-javadoc-plugin&package-manager=maven&previous-version=3.7.0&new-version=3.8.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it
- `@dependabot merge` will merge this PR after your CI passes on it
- `@dependabot squash and merge` will squash and merge this PR after your CI passes on it
- `@dependabot cancel merge` will cancel a previously requested merge and block automerging
- `@dependabot reopen` will reopen this PR if it is closed
- `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually
- `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)


</details>


Patch:
diff --git a/README.md b/README.md
index 736685d..f1b8180 100644
--- a/README.md
+++ b/README.md
@@ -18,9 +18,9 @@ When used as a mock, it allows you to test behaviour dependent on Redis without
 * TestContainers require Docker. Jedis-Mock is just a Maven dependency which, when used as 'pure' mock, can be run on any machine, right now.
 * TestContainers tests can be slow and resource-consuming. Jedis-Mock tests are lightning fast, which
 encourages developers to write more tests and run them more often.
-* Redis running in TestContainers is a "black box". We cannot verify what was actually called. 
-  We cannot interfere with the reply. All this we can do with testing mock/proxy.
-* We can use cluster connection APIs (e. g. `JedisCluster`) without spinning up 3 instances of Redis.
+* While Redis running in TestContainers is a "black box", Jedis-Mock facilitates "white box" testing: we can verify what was actually called and interfere with the reply via a [command interceptor](#interceptor).
+* With Jedis-Mock in [cluster emulation mode](#cluster), one can use cluster connection APIs (e. g. `JedisCluster`) without spinning up 3 instances of Redis.
+* Using [clock injection](#clockinjection), we can "fast forward" and avoid waiting before keys expiration, or, on the contrary, "freeze time" and be guaranteed that keys are not expired before the end of the test scenario.   
 * If you wish, you can use Jedis-Mock *together* with TestContainers, delegating command execution 
   to a real Redis instance, intercepting some of the calls when needed.
 
@@ -72,7 +72,7 @@ RedissonClient client = Redisson.create(config);
 
 From here test as needed.
 
-## Cluster mode support
+## <a name="cluster">Cluster mode support</a>
 
 Sometimes you need to use cluster connection APIs in your tests. Jedis-Mock can emulate "cluster mode" by mocking a single node holding all the hash slots (0-16383) so that common connectivity libraries can successfully connect and work. Just use `withClusterModeEnabled()` for `ServiceOptions`:
 
@@ -95,7 +95,7 @@ RedisClusterClient redisClient = RedisClusterClient
 
 Note that support of `CLUSTER` subcommands is limited to the  minimum that is necessary for successful usage of `JedisCluster`/`RedisClusterClient`.
 
-## Using `RedisCommandInterceptor`
+## <a name="interceptor">Using `RedisCommandInterceptor`</a>
 
 `RedisCommandInterceptor` is a functional interface which can be used to intercept calls to Jedis-Mock. 
 You can use it as following:
@@ -171,6 +171,47 @@ jedis.lrange("mylist", 0, -1));
 
 Feel free to report an issue if you have any problems with Lua scripting in Jedis-Mock.
 
+## <a name="clockinjection">Clock injection</a>
+
+[`java.time.Clock`](https://docs.oracle.com/javase/8/docs/api/java/time/Clock.html) injection is supported via `setClock` method on `RedisServer`. The injected clock is used for calculation and verification of the keys expiration time. Thus, it's possible to "freeze" or "skip" time for better testing of keys expiration. `setClock` is a thread-safe method which can be called as needed from any place of the code.
+
+### "Freezing" time
+
+```java
+jedis.setex("key1", 1, "v1");
+server.setClock(Clock.fixed(Instant.now(), ZoneId.systemDefault()));
+Thread.sleep(1500);
+//The key must have expired, but the time is stopped
+assertThat(jedis.exists("key1")).isTrue();
+```
+### Skipping time
+```java
+jedis.setex("key1", 20, "v1");
+jedis.setex("key2", 40, "v2");
+
+//Skipping 30 seconds: key1 expires, key2 not yet
+server.setClock(Clock.offset(server.getClock(), Duration.ofSeconds(30)));
+assertThat(jedis.exists("key1")).isFalse();
+assertThat(jedis.exists("key2")).isTrue();
+
+//Skipping another 30 seconds: both keys expire
+server.setClock(Clock.offset(server.getClock(), Duration.ofSeconds(30)));
+assertThat(jedis.exists("key1")).isFalse();
+assertThat(jedis.exists("key2")).isFalse();
+```
+
+### Setting time in the past
+```java
+jedis.setex("key1", 1, "v1");
+//Give extra two seconds for the key to expire
+server.setClock(Clock.offset(server.getClock(), Duration.ofSeconds(-2)));
+Thread.sleep(1500);
+assertThat(jedis.exists("key1")).isTrue();
+Thread.sleep(1600);
+assertThat(jedis.exists("key1")).isFalse();
+```
+:warning: Setting time in the past does not "magically revive" the keys that have already been expired, although it provides extra time before the expiration of the keys still present in the database. Also, clock injection does not change the semantics of waiting operations: e.g. `BLPOP mylist 10` will be waiting for the period of 10 seconds regardless of the changes in the injected clock.
+
 ## Supported and Missing Operations
 
 All currently supported and missing operations are listed [here](supported_operations.md).
diff --git a/checkstyle.xml b/checkstyle.xml
index a7ac8ce..6b1c5fd 100644
--- a/checkstyle.xml
+++ b/checkstyle.xml
@@ -100,6 +100,12 @@
         <module name="UpperEll"/>
         <module name="SuppressWarningsHolder"/>
 
+        <!-- Prohibit usage of System.currentTimeMillis()-->
+        <module name="Regexp">
+            <property name="format" value="currentTimeMillis *\( *\)"/>
+            <property name="illegalPattern" value="true"/>
+            <property name="message" value="currentTimeMillis() invocation detected. Use injected clock instead."/>
+        </module>
     </module>
     <module name="NewlineAtEndOfFile"/>
 
diff --git a/pom.xml b/pom.xml
index e521167..cda3653 100644
--- a/pom.xml
+++ b/pom.xml
@@ -101,7 +101,7 @@
         <dependency>
             <groupId>io.netty</groupId>
             <artifactId>netty-all</artifactId>
-            <version>4.1.111.Final</version>
+            <version>4.1.112.Final</version>
             <scope>test</scope>
         </dependency>
         <dependency>
@@ -197,7 +197,7 @@
             <plugin>
                 <groupId>org.apache.maven.plugins</groupId>
                 <artifactId>maven-javadoc-plugin</artifactId>
-                <version>3.7.0</version>
+                <version>3.8.0</version>
                 <executions>
                     <execution>
                         <id>attach-javadocs</id>
diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml
index 3b6a5d6..f4f5167 100644
--- a/spotbugs-exclude.xml
+++ b/spotbugs-exclude.xml
@@ -11,13 +11,9 @@
             <Bug pattern="DM_CONVERT_CASE"/>
         </Or>
     </Match>
-
     <Match>
-        <Class name="com.github.fppt.jedismock.server.RedisService"/>
-        <Or>
-            <Bug pattern="RV_RETURN_VALUE_IGNORED_BAD_PRACTICE"/>
-            <Bug pattern="THROWS_METHOD_THROWS_CLAUSE_BASIC_EXCEPTION"/>
-        </Or>
+        <Class name="com.github.fppt.jedismock.RedisServer$RedisServiceJob"/>
+        <Bug pattern="RV_RETURN_VALUE_IGNORED_BAD_PRACTICE"/>
     </Match>
     <Match>
         <!--False positive as of Spotbugs 4.8.4.0-->
diff --git a/src/main/java/com/github/fppt/jedismock/server/RedisClient.java b/src/main/java/com/github/fppt/jedismock/RedisClient.java
similarity index 87%
rename from src/main/java/com/github/fppt/jedismock/server/RedisClient.java
rename to src/main/java/com/github/fppt/jedismock/RedisClient.java
index 194c53b..28f31ea 100644
--- a/src/main/java/com/github/fppt/jedismock/server/RedisClient.java
+++ b/src/main/java/com/github/fppt/jedismock/RedisClient.java
@@ -1,12 +1,13 @@
-package com.github.fppt.jedismock.server;
+package com.github.fppt.jedismock;
 
 import com.github.fppt.jedismock.datastructures.Slice;
 import com.github.fppt.jedismock.exception.EOFException;
+import com.github.fppt.jedismock.server.RedisOperationExecutor;
+import com.github.fppt.jedismock.server.Response;
+import com.github.fppt.jedismock.server.ServiceOptions;
 import com.github.fppt.jedismock.storage.OperationExecutorState;
-import com.github.fppt.jedismock.storage.RedisBase;
 import com.github.fppt.jedismock.commands.RedisCommand;
 import com.github.fppt.jedismock.commands.RedisCommandParser;
-import com.github.fppt.jedismock.Utils;
 import com.github.fppt.jedismock.exception.ParseErrorException;
 import org.slf4j.LoggerFactory;
 
@@ -14,7 +15,7 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.net.Socket;
-import java.util.Map;
+import java.time.Clock;
 import java.util.Objects;
 import java.util.Optional;
 import java.util.concurrent.atomic.AtomicBoolean;
@@ -25,26 +26,25 @@ import java.util.function.Consumer;
  */
 public final class RedisClient implements Runnable {
     private static final org.slf4j.Logger LOG = LoggerFactory.getLogger(RedisClient.class);
+    private final RedisServer server;
+    private final Socket socket;
     private final AtomicBoolean running;
     private final RedisOperationExecutor executor;
-    private final Socket socket;
-    private final ServiceOptions options;
     private final InputStream in;
     private final OutputStream out;
     private final Consumer<RedisClient> onClose;
 
-    RedisClient(Map<Integer, RedisBase> redisBases,
+    RedisClient(RedisServer server,
                 Socket socket,
-                ServiceOptions options,
                 Consumer<RedisClient> onClose) throws IOException {
-        Objects.requireNonNull(redisBases);
+        Objects.requireNonNull(server);
         Objects.requireNonNull(socket);
-        Objects.requireNonNull(options);
         Objects.requireNonNull(onClose);
-        OperationExecutorState state = new OperationExecutorState(this, redisBases);
+        this.server = server;
+        OperationExecutorState state = new OperationExecutorState(this,
+                server.getRedisBases());
         this.executor = new RedisOperationExecutor(state);
         this.socket = socket;
-        this.options = options;
         this.in = socket.getInputStream();
         this.out = socket.getOutputStream();
         this.running = new AtomicBoolean(true);
@@ -107,13 +107,17 @@ public final class RedisClient implements Runnable {
     }
 
     public ServiceOptions options() {
-        return options;
+        return server.options();
     }
 
     public int getPort() {
         return socket.getLocalPort();
     }
 
+    public Clock getClock() {
+        return server.getClock();
+    }
+
     public String getAddress() {
         return socket.getInetAddress().getHostAddress();
     }
diff --git a/src/main/java/com/github/fppt/jedismock/RedisServer.java b/src/main/java/com/github/fppt/jedismock/RedisServer.java
index 8420189..cbe3eae 100644
--- a/src/main/java/com/github/fppt/jedismock/RedisServer.java
+++ b/src/main/java/com/github/fppt/jedismock/RedisServer.java
@@ -1,15 +1,20 @@
 package com.github.fppt.jedismock;
 
 import com.github.fppt.jedismock.operations.CommandFactory;
-import com.github.fppt.jedismock.server.RedisService;
 import com.github.fppt.jedismock.server.ServiceOptions;
 import com.github.fppt.jedismock.storage.RedisBase;
 
 import java.io.IOException;
 import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.time.Clock;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.Objects;
+import java.util.concurrent.Callable;
+import java.util.concurrent.CopyOnWriteArrayList;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
@@ -25,8 +30,9 @@ public class RedisServer {
     private final int bindPort;
     private final InetAddress bindAddress;
     private final Map<Integer, RedisBase> redisBases;
-    private volatile ExecutorService threadPool;
-    private volatile RedisService service;
+    private volatile ExecutorService singleThreadPool;
+    private volatile RedisServiceJob service;
+    private volatile Clock clock = Clock.systemDefaultZone();
     private volatile ServiceOptions options = ServiceOptions.defaultOptions();
     private volatile Future<Void> serviceFinalization;
 
@@ -59,8 +65,12 @@ public class RedisServer {
     }
 
     public RedisServer setOptions(ServiceOptions options) {
-        Objects.requireNonNull(options);
-        this.options = options;
+        this.options = Objects.requireNonNull(options);
+        return this;
+    }
+
+    public RedisServer setClock(Clock clock) {
+        this.clock = Objects.requireNonNull(clock);
         return this;
     }
 
@@ -68,9 +78,9 @@ public class RedisServer {
         if (!(service == null)) {
             throw new IllegalStateException();
         }
-        this.service = new RedisService(bindPort, bindAddress, redisBases, options);
-        threadPool = Executors.newSingleThreadExecutor();
-        serviceFinalization = threadPool.submit(service);
+        this.service = new RedisServiceJob();
+        singleThreadPool = Executors.newSingleThreadExecutor();
+        serviceFinalization = singleThreadPool.submit(service);
         return this;
     }
 
@@ -86,7 +96,7 @@ public class RedisServer {
             System.err.println("Jedis-mock interrupted while stopping");
             Thread.currentThread().interrupt();
         } finally {
-            threadPool.shutdownNow();
+            singleThreadPool.shutdownNow();
         }
     }
 
@@ -103,4 +113,50 @@ public class RedisServer {
         Objects.requireNonNull(service, NOT_RUNNING);
         return service.getServer().getLocalPort();
     }
+
+    Map<Integer, RedisBase> getRedisBases() {
+        return redisBases;
+    }
+
+    public ServiceOptions options() {
+        return options;
+    }
+
+    public Clock getClock() {
+        return clock;
+    }
+
+    private final class RedisServiceJob implements Callable<Void> {
+
+        private final ServerSocket server;
+        private final ExecutorService threadPool = Executors.newCachedThreadPool();
+        private final List<RedisClient> clients = new CopyOnWriteArrayList<>();
+
+        public RedisServiceJob() throws IOException {
+            Objects.requireNonNull(redisBases);
+            Objects.requireNonNull(options);
+            this.server = new ServerSocket(bindPort, 0, bindAddress);
+        }
+
+        public Void call() throws IOException {
+            while (!server.isClosed()) {
+                Socket socket = server.accept();
+                RedisClient rc = new RedisClient(RedisServer.this, socket, clients::remove);
+                clients.add(rc);
+                threadPool.submit(rc);
+            }
+            return null;
+        }
+
+        public ServerSocket getServer() {
+            return server;
+        }
+
+        public void stop() throws IOException {
+            clients.forEach(RedisClient::close);
+            server.close();
+            threadPool.shutdownNow();
+        }
+    }
+
 }
diff --git a/src/main/java/com/github/fppt/jedismock/operations/keys/PExpire.java b/src/main/java/com/github/fppt/jedismock/operations/keys/PExpire.java
index 93281e2..ef422a7 100644
--- a/src/main/java/com/github/fppt/jedismock/operations/keys/PExpire.java
+++ b/src/main/java/com/github/fppt/jedismock/operations/keys/PExpire.java
@@ -55,7 +55,7 @@ class PExpire extends AbstractRedisOperation {
         try {
             newTTL = getValue(params());
             //Check for potential overflow
-            Math.addExact(newTTL, System.currentTimeMillis());
+            Math.addExact(newTTL, base().getClock().millis());
         } catch (ArithmeticException e) {
             return Response.error(
                     String.format("ERR invalid expire time in '%s' command",
diff --git a/src/main/java/com/github/fppt/jedismock/operations/pubsub/Publish.java b/src/main/java/com/github/fppt/jedismock/operations/pubsub/Publish.java
index f180dd5..34aa97e 100644
--- a/src/main/java/com/github/fppt/jedismock/operations/pubsub/Publish.java
+++ b/src/main/java/com/github/fppt/jedismock/operations/pubsub/Publish.java
@@ -2,7 +2,7 @@ package com.github.fppt.jedismock.operations.pubsub;
 
 import com.github.fppt.jedismock.operations.AbstractRedisOperation;
 import com.github.fppt.jedismock.operations.RedisCommand;
-import com.github.fppt.jedismock.server.RedisClient;
+import com.github.fppt.jedismock.RedisClient;
 import com.github.fppt.jedismock.server.Response;
 import com.github.fppt.jedismock.datastructures.Slice;
 import com.github.fppt.jedismock.storage.RedisBase;
diff --git a/src/main/java/com/github/fppt/jedismock/operations/server/Time.java b/src/main/java/com/github/fppt/jedismock/operations/server/Time.java
index 55d40a7..21e197e 100644
--- a/src/main/java/com/github/fppt/jedismock/operations/server/Time.java
+++ b/src/main/java/com/github/fppt/jedismock/operations/server/Time.java
@@ -10,15 +10,21 @@ import java.util.List;
 
 @RedisCommand("time")
 public class Time extends AbstractRedisOperation {
+
     Time(RedisBase base, List<Slice> params) {
         super(base, params);
     }
 
+    @Override
+    protected int maxArgs() {
+        return 0;
+    }
+
     @Override
     protected Slice response() {
         //Java8 has wallclock with microseconds precision,
         //so this mock returns results truncated up to a millisecond
-        long time = System.currentTimeMillis();
+        long time = base().getClock().millis();
         long seconds = time / 1000L;
         long microseconds = (time % 1000L) * 1000L;
         return Response.array(
diff --git a/src/main/java/com/github/fppt/jedismock/operations/strings/Set.java b/src/main/java/com/github/fppt/jedismock/operations/strings/Set.java
index cfe4c18..b510e73 100644
--- a/src/main/java/com/github/fppt/jedismock/operations/strings/Set.java
+++ b/src/main/java/com/github/fppt/jedismock/operations/strings/Set.java
@@ -79,7 +79,7 @@ class Set extends AbstractRedisOperation {
         }
         try {
             value = Math.multiplyExact(multiplier, value);
-            Math.addExact(System.currentTimeMillis(), value);
+            Math.addExact(base().getClock().millis(), value);
         } catch (ArithmeticException e) {
             throw new IllegalArgumentException(String.format(
                     "ERR invalid expire time in '%s' command", self().value()));
diff --git a/src/main/java/com/github/fppt/jedismock/operations/strings/SetEx.java b/src/main/java/com/github/fppt/jedismock/operations/strings/SetEx.java
index 4e0d674..659e24c 100644
--- a/src/main/java/com/github/fppt/jedismock/operations/strings/SetEx.java
+++ b/src/main/java/com/github/fppt/jedismock/operations/strings/SetEx.java
@@ -23,7 +23,7 @@ class SetEx extends Set {
         final long timeout;
         try {
             timeout = timeoutToSet(params());
-            Math.addExact(System.currentTimeMillis(), timeout);
+            Math.addExact(base().getClock().millis(), timeout);
         } catch (ArithmeticException e) {
             return Response.error(String.format("ERR invalid expire time in '%s' command", self().value()));
         }
diff --git a/src/main/java/com/github/fppt/jedismock/server/RedisService.java b/src/main/java/com/github/fppt/jedismock/server/RedisService.java
deleted file mode 100644
index ac225d3..0000000
--- a/src/main/java/com/github/fppt/jedismock/server/RedisService.java
+++ /dev/null
@@ -1,57 +0,0 @@
-package com.github.fppt.jedismock.server;
-
-import com.github.fppt.jedismock.storage.RedisBase;
-
-import java.io.IOException;
-import java.net.InetAddress;
-import java.net.ServerSocket;
-import java.net.Socket;
-import java.util.List;
-import java.util.Map;
-import java.util.Objects;
-import java.util.concurrent.Callable;
-import java.util.concurrent.CopyOnWriteArrayList;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
-
-/**
- * Created by Xiaolu on 2015/4/21.
- */
-public final class RedisService implements Callable<Void> {
-
-    private final ServerSocket server;
-    private final Map<Integer, RedisBase> redisBases;
-    private final ServiceOptions options;
-    private final ExecutorService threadPool = Executors.newCachedThreadPool();
-    private final List<RedisClient> clients = new CopyOnWriteArrayList<>();
-
-    public RedisService(int bindPort, InetAddress address,
-                        Map<Integer, RedisBase> redisBases, ServiceOptions options) throws IOException {
-        Objects.requireNonNull(redisBases);
-        Objects.requireNonNull(options);
-
-        this.server = new ServerSocket(bindPort, 0, address);
-        this.redisBases = redisBases;
-        this.options = options;
-    }
-
-    public Void call() throws IOException {
-        while (!server.isClosed()) {
-            Socket socket = server.accept();
-            RedisClient rc = new RedisClient(redisBases, socket, options, clients::remove);
-            clients.add(rc);
-            threadPool.submit(rc);
-        }
-        return null;
-    }
-
-    public ServerSocket getServer() {
-        return server;
-    }
-
-    public void stop() throws IOException {
-        clients.forEach(RedisClient::close);
-        server.close();
-        threadPool.shutdownNow();
-    }
-}
diff --git a/src/main/java/com/github/fppt/jedismock/storage/ExpiringKeyValueStorage.java b/src/main/java/com/github/fppt/jedismock/storage/ExpiringKeyValueStorage.java
index 27bb888..9fe40b8 100644
--- a/src/main/java/com/github/fppt/jedismock/storage/ExpiringKeyValueStorage.java
+++ b/src/main/java/com/github/fppt/jedismock/storage/ExpiringKeyValueStorage.java
@@ -4,18 +4,22 @@ import com.github.fppt.jedismock.datastructures.RMDataStructure;
 import com.github.fppt.jedismock.datastructures.RMHash;
 import com.github.fppt.jedismock.datastructures.Slice;
 
+import java.time.Clock;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Objects;
 import java.util.function.Consumer;
+import java.util.function.Supplier;
 
 public class ExpiringKeyValueStorage {
+    private final Supplier<Clock> clockSupplier;
     private final Map<Slice, RMDataStructure> values = new HashMap<>();
     private final Map<Slice, Long> ttls = new HashMap<>();
     private final Consumer<Slice> keyChangeNotifier;
 
-    public ExpiringKeyValueStorage(Consumer<Slice> keyChangeNotifier) {
-        this.keyChangeNotifier = keyChangeNotifier;
+    public ExpiringKeyValueStorage(Supplier<Clock> clockSupplier, Consumer<Slice> keyChangeNotifier) {
+        this.clockSupplier = Objects.requireNonNull(clockSupplier);
+        this.keyChangeNotifier = Objects.requireNonNull(keyChangeNotifier);
     }
 
     public Map<Slice, RMDataStructure> values() {
@@ -89,7 +93,7 @@ public class ExpiringKeyValueStorage {
 
     boolean isKeyOutdated(Slice key) {
         Long deadline = ttls().get(key);
-        return deadline != null && deadline != -1 && deadline <= System.currentTimeMillis();
+        return deadline != null && deadline != -1 && deadline <= getMillis();
     }
 
     public Long getTTL(Slice key) {
@@ -101,7 +105,7 @@ public class ExpiringKeyValueStorage {
         if (deadline == -1) {
             return deadline;
         }
-        long now = System.currentTimeMillis();
+        long now = getMillis();
         if (now < deadline) {
             return deadline - now;
         }
@@ -111,7 +115,11 @@ public class ExpiringKeyValueStorage {
 
     public long setTTL(Slice key, long ttl) {
         keyChangeNotifier.accept(key);
-        return setDeadline(key, ttl + System.currentTimeMillis());
+        return setDeadline(key, ttl + getMillis());
+    }
+
+    private long getMillis() {
+        return clockSupplier.get().millis();
     }
 
     public void put(Slice key, RMDataStructure value, Long ttl) {
diff --git a/src/main/java/com/github/fppt/jedismock/storage/OperationExecutorState.java b/src/main/java/com/github/fppt/jedismock/storage/OperationExecutorState.java
index 715005e..5e0678d 100644
--- a/src/main/java/com/github/fppt/jedismock/storage/OperationExecutorState.java
+++ b/src/main/java/com/github/fppt/jedismock/storage/OperationExecutorState.java
@@ -2,8 +2,9 @@ package com.github.fppt.jedismock.storage;
 
 import com.github.fppt.jedismock.datastructures.Slice;
 import com.github.fppt.jedismock.operations.RedisOperation;
-import com.github.fppt.jedismock.server.RedisClient;
+import com.github.fppt.jedismock.RedisClient;
 
+import java.time.Clock;
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Map;
@@ -32,7 +33,7 @@ public class OperationExecutorState {
     }
 
     public RedisBase base(int baseIndex) {
-        return redisBases.computeIfAbsent(baseIndex, key -> new RedisBase());
+        return redisBases.computeIfAbsent(baseIndex, key -> new RedisBase(this::getClock));
     }
 
     public RedisClient owner() {
@@ -113,6 +114,10 @@ public class OperationExecutorState {
         return owner.getPort();
     }
 
+    public Clock getClock() {
+        return owner.getClock();
+    }
+
     public String getHost() {
         return owner.getAddress();
     }
diff --git a/src/main/java/com/github/fppt/jedismock/storage/RedisBase.java b/src/main/java/com/github/fppt/jedismock/storage/RedisBase.java
index f19fe96..4468b41 100644
--- a/src/main/java/com/github/fppt/jedismock/storage/RedisBase.java
+++ b/src/main/java/com/github/fppt/jedismock/storage/RedisBase.java
@@ -11,8 +11,9 @@ import com.github.fppt.jedismock.datastructures.streams.RMStream;
 import com.github.fppt.jedismock.datastructures.RMString;
 import com.github.fppt.jedismock.datastructures.RMZSet;
 import com.github.fppt.jedismock.datastructures.Slice;
-import com.github.fppt.jedismock.server.RedisClient;
+import com.github.fppt.jedismock.RedisClient;
 
+import java.time.Clock;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.HashMap;
@@ -20,20 +21,31 @@ import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
+import java.util.function.Supplier;
 
 /**
  * Created by Xiaolu on 2015/4/20.
  */
 public class RedisBase {
+    private final Supplier<Clock> clockSupplier;
     private final Map<Slice, Set<RedisClient>> subscribers = new HashMap<>();
     private final Map<Slice, Set<RedisClient>> psubscribers = new HashMap<>();
     private final Map<Slice, Set<OperationExecutorState>> watchedKeys = new HashMap<>();
-    private final ExpiringKeyValueStorage keyValueStorage =
-            new ExpiringKeyValueStorage(key -> watchedKeys
-                    .getOrDefault(key, Collections.emptySet())
-                    .forEach(OperationExecutorState::watchedKeyIsAffected));
     private final Map<String, String> cachedLuaScripts = new HashMap<>();
+    private final ExpiringKeyValueStorage keyValueStorage;
+
+    public RedisBase(Supplier<Clock> clockSupplier) {
+        this.clockSupplier = Objects.requireNonNull(clockSupplier);
+        this.keyValueStorage = new ExpiringKeyValueStorage(clockSupplier, key -> watchedKeys
+                .getOrDefault(key, Collections.emptySet())
+                .forEach(OperationExecutorState::watchedKeyIsAffected));
+    }
+
+    public Clock getClock() {
+        return clockSupplier.get();
+    }
 
     public Set<Slice> keys() {
         Iterator<Slice> slices = keyValueStorage.values().keySet().iterator();


Test patch:
diff --git a/src/test/java/com/github/fppt/jedismock/server/RedisClientTest.java b/src/test/java/com/github/fppt/jedismock/RedisClientTest.java
similarity index 76%
rename from src/test/java/com/github/fppt/jedismock/server/RedisClientTest.java
rename to src/test/java/com/github/fppt/jedismock/RedisClientTest.java
index 59cb44b..ece66d9 100644
--- a/src/test/java/com/github/fppt/jedismock/server/RedisClientTest.java
+++ b/src/test/java/com/github/fppt/jedismock/RedisClientTest.java
@@ -1,4 +1,4 @@
-package com.github.fppt.jedismock.server;
+package com.github.fppt.jedismock;
 
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -8,7 +8,6 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
 import java.net.Socket;
-import java.util.Collections;
 
 class RedisClientTest {
     Socket s;
@@ -19,7 +18,7 @@ class RedisClientTest {
         s = Mockito.mock(Socket.class);
         Mockito.when(s.getInputStream()).thenReturn(Mockito.mock(InputStream.class));
         Mockito.when(s.getOutputStream()).thenReturn(Mockito.mock(OutputStream.class));
-        redisClient = new RedisClient(Collections.emptyMap(), s, ServiceOptions.defaultOptions(), c -> {
+        redisClient = new RedisClient(Mockito.mock(RedisServer.class), s, c -> {
         });
     }
 
diff --git a/src/test/java/com/github/fppt/jedismock/TestInjectedClock.java b/src/test/java/com/github/fppt/jedismock/TestInjectedClock.java
new file mode 100644
index 0000000..1795a2f
--- /dev/null
+++ b/src/test/java/com/github/fppt/jedismock/TestInjectedClock.java
@@ -0,0 +1,91 @@
+package com.github.fppt.jedismock;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import redis.clients.jedis.Jedis;
+import redis.clients.jedis.Pipeline;
+
+import java.io.IOException;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+public class TestInjectedClock {
+    RedisServer server;
+    Jedis jedis;
+
+
+    @BeforeEach
+    void setup() throws IOException {
+        server = RedisServer.newRedisServer();
+        server.start();
+        jedis = new Jedis(server.getHost(), server.getBindPort());
+    }
+
+    @AfterEach
+    void tearDown() throws IOException {
+        jedis.close();
+        server.stop();
+    }
+
+    @Test
+    public void testTime() throws InterruptedException {
+        server.setClock(Clock.fixed(Instant.ofEpochMilli(123456789123L), ZoneId.systemDefault()));
+        Thread.sleep(100);
+        assertThat(jedis.time()).containsExactly("123456789", "123000");
+    }
+
+    @Test
+    public void testExpireTimeForFixedClock() throws InterruptedException {
+        server.setClock(Clock.fixed(Instant.ofEpochMilli(777777777000L), ZoneId.systemDefault()));
+        jedis.setex("foo", 1, "bar");
+        assertThat(jedis.expireTime("foo")).isEqualTo(777777778L);
+        Thread.sleep(1500);
+        //The key does not expire as the time is frozen
+        assertThat(jedis.exists("foo")).isTrue();
+    }
+
+    @Test
+    public void setTimeInFuture() {
+        jedis.setex("key1", 20, "v1");
+        jedis.setex("key2", 40, "v2");
+        server.setClock(Clock.offset(
+                server.getClock(), Duration.ofSeconds(30)
+        ));
+        //Skipped 30 seconds: key1 key must be expired, key2 -- not yet
+        assertThat(jedis.exists("key1")).isFalse();
+        assertThat(jedis.exists("key2")).isTrue();
+        server.setClock(Clock.offset(
+                server.getClock(), Duration.ofSeconds(30)
+        ));
+        //Skipped another 30 seconds: both keys expired
+        assertThat(jedis.exists("key1")).isFalse();
+        assertThat(jedis.exists("key2")).isFalse();
+    }
+
+    @Test
+    public void setTimeInThePast() throws InterruptedException {
+        jedis.setex("key1", 1, "v1");
+        server.setClock(Clock.offset(
+                server.getClock(), Duration.ofSeconds(-2)
+        ));
+        Thread.sleep(1500);
+        assertThat(jedis.exists("key1")).isTrue();
+        Thread.sleep(1600);
+        assertThat(jedis.exists("key1")).isFalse();
+    }
+
+    @Test
+    void freezeKey() throws InterruptedException {
+        jedis.setex("key1", 1, "v1");
+        server.setClock(Clock.fixed(Instant.now(), ZoneId.systemDefault()));
+        Thread.sleep(1500);
+        //The key must have expired, the time is stopped
+        assertThat(jedis.exists("key1")).isTrue();
+    }
+}
diff --git a/src/test/java/com/github/fppt/jedismock/server/TestRedisOperationExecutor.java b/src/test/java/com/github/fppt/jedismock/TestRedisOperationExecutor.java
similarity index 92%
rename from src/test/java/com/github/fppt/jedismock/server/TestRedisOperationExecutor.java
rename to src/test/java/com/github/fppt/jedismock/TestRedisOperationExecutor.java
index 35abd82..43e468b 100644
--- a/src/test/java/com/github/fppt/jedismock/server/TestRedisOperationExecutor.java
+++ b/src/test/java/com/github/fppt/jedismock/TestRedisOperationExecutor.java
@@ -1,7 +1,9 @@
-package com.github.fppt.jedismock.server;
+package com.github.fppt.jedismock;
 
 import com.github.fppt.jedismock.commands.RedisCommandParser;
 import com.github.fppt.jedismock.exception.ParseErrorException;
+import com.github.fppt.jedismock.server.RedisOperationExecutor;
+import com.github.fppt.jedismock.server.ServiceOptions;
 import com.github.fppt.jedismock.storage.OperationExecutorState;
 import com.github.fppt.jedismock.storage.RedisBase;
 import org.junit.jupiter.api.BeforeEach;
@@ -10,8 +12,10 @@ import org.mockito.Mockito;
 
 import java.io.IOException;
 import java.net.Socket;
+import java.time.Clock;
 import java.util.HashMap;
 import java.util.Map;
+import java.util.concurrent.atomic.AtomicReference;
 
 import static com.github.fppt.jedismock.commands.RedisCommandParser.parse;
 import static com.github.fppt.jedismock.server.Response.NULL;
@@ -79,15 +83,20 @@ public class TestRedisOperationExecutor {
         assertThat(executor.execCommand(parse(command)).data()[0]).isEqualTo((byte) '-');
     }
 
-    private String del(String key){ return executor.execCommand(RedisCommandParser.parse(array("DEL", key))).toString(); }
-    private String set(String key, String value){ return executor.execCommand(RedisCommandParser.parse(array("SET", key, value))).toString(); }
+    private String del(String key) {
+        return executor.execCommand(RedisCommandParser.parse(array("DEL", key))).toString();
+    }
+
+    private String set(String key, String value) {
+        return executor.execCommand(RedisCommandParser.parse(array("SET", key, value))).toString();
+    }
 
     @BeforeEach
     public void initCommandExecutor() throws IOException {
         Map<Integer, RedisBase> redisBases = new HashMap<>();
-        redisBases.put(0, new RedisBase());
-        RedisClient redisClient = new RedisClient(redisBases,
-                mockSocket, ServiceOptions.defaultOptions(), c -> {
+        redisBases.put(0, new RedisBase(new AtomicReference<>(Clock.systemDefaultZone())::get));
+        RedisClient redisClient = new RedisClient(
+                new RedisServer(), mockSocket, c -> {
         });
         OperationExecutorState state = new OperationExecutorState(redisClient, redisBases);
         executor = new RedisOperationExecutor(state);
@@ -104,7 +113,8 @@ public class TestRedisOperationExecutor {
     }
 
     // SET key value NX
-    @Test public void testSetNX() throws ParseErrorException {
+    @Test
+    public void testSetNX() throws ParseErrorException {
         assertCommandOK(array("SET", "key", "value", "NX"));
         assertCommandEquals("value", array("GET", "key"));
         assertCommandNull(array("SET", "key", "value", "NX"));
@@ -112,7 +122,8 @@ public class TestRedisOperationExecutor {
     }
 
     // SET key value XX
-    @Test public void testSetXX() throws ParseErrorException {
+    @Test
+    public void testSetXX() throws ParseErrorException {
         del("key");
         assertCommandNull(array("SET", "key", "value", "XX"));
         assertCommandOK(array("SET", "key", "value"));
@@ -121,7 +132,8 @@ public class TestRedisOperationExecutor {
     }
 
     // SET key value EX s
-    @Test public void testSetEX() throws ParseErrorException, InterruptedException {
+    @Test
+    public void testSetEX() throws ParseErrorException, InterruptedException {
         assertCommandOK(array("SET", "key", "value", "EX", "1"));
         assertCommandEquals("value", array("GET", "key"));
         Thread.sleep(1000);
@@ -129,7 +141,8 @@ public class TestRedisOperationExecutor {
     }
 
     // SET key value PX ms
-    @Test public void testSetPX() throws ParseErrorException, InterruptedException {
+    @Test
+    public void testSetPX() throws ParseErrorException, InterruptedException {
         assertCommandOK(array("SET", "key", "value", "PX", "1000"));
         assertCommandEquals("value", array("GET", "key"));
         Thread.sleep(1000);
@@ -137,13 +150,16 @@ public class TestRedisOperationExecutor {
     }
 
     // SET key value EX s NX
-    @Test public void testSetEXNXexpires() throws ParseErrorException, InterruptedException {
+    @Test
+    public void testSetEXNXexpires() throws ParseErrorException, InterruptedException {
         assertCommandOK(array("SET", "key", "value", "EX", "1", "NX"));
         assertCommandEquals("value", array("GET", "key"));
         Thread.sleep(1000);
         assertCommandNull(array("GET", "key"));
     }
-    @Test public void testSetEXNXnotexists() throws ParseErrorException {
+
+    @Test
+    public void testSetEXNXnotexists() throws ParseErrorException {
         assertCommandOK(array("SET", "key", "value", "EX", "1", "NX"));
         assertCommandEquals("value", array("GET", "key"));
         assertCommandNull(array("SET", "key", "value", "EX", "1", "NX"));
@@ -151,13 +167,16 @@ public class TestRedisOperationExecutor {
     }
 
     // SET key value PX ms NX
-    @Test public void testSetPXNXexpires() throws ParseErrorException, InterruptedException {
+    @Test
+    public void testSetPXNXexpires() throws ParseErrorException, InterruptedException {
         assertCommandOK(array("SET", "key", "value", "PX", "1000", "NX"));
         assertCommandEquals("value", array("GET", "key"));
         Thread.sleep(1000);
         assertCommandNull(array("GET", "key"));
     }
-    @Test public void testSetPXNXnotexists() throws ParseErrorException {
+
+    @Test
+    public void testSetPXNXnotexists() throws ParseErrorException {
         assertCommandOK(array("SET", "key", "value", "PX", "1000", "NX"));
         assertCommandEquals("value", array("GET", "key"));
         assertCommandNull(array("SET", "key", "value", "PX", "1000", "NX"));
@@ -165,14 +184,17 @@ public class TestRedisOperationExecutor {
     }
 
     // SET key value EX s XX
-    @Test public void testSetEXXXexpires() throws ParseErrorException, InterruptedException {
+    @Test
+    public void testSetEXXXexpires() throws ParseErrorException, InterruptedException {
         set("key", "value");
         assertCommandOK(array("SET", "key", "value", "EX", "1", "XX"));
         assertCommandEquals("value", array("GET", "key"));
         Thread.sleep(1000);
         assertCommandNull(array("GET", "key"));
     }
-    @Test public void testSetEXXXnotexists() throws ParseErrorException {
+
+    @Test
+    public void testSetEXXXnotexists() throws ParseErrorException {
         del("key");
         assertCommandNull(array("SET", "key", "value", "EX", "1", "XX"));
         assertCommandOK(array("SET", "key", "value"));
@@ -181,14 +203,17 @@ public class TestRedisOperationExecutor {
     }
 
     // SET key value PX ms XX
-    @Test public void testSetPXXXexpires() throws ParseErrorException, InterruptedException {
+    @Test
+    public void testSetPXXXexpires() throws ParseErrorException, InterruptedException {
         set("key", "value");
         assertCommandOK(array("SET", "key", "value", "PX", "1000", "XX"));
         assertCommandEquals("value", array("GET", "key"));
         Thread.sleep(1000);
         assertCommandNull(array("GET", "key"));
     }
-    @Test public void testSetPXXXnotexists() throws ParseErrorException {
+
+    @Test
+    public void testSetPXXXnotexists() throws ParseErrorException {
         del("key");
         assertCommandNull(array("SET", "key", "value", "PX", "1000", "XX"));
         assertCommandOK(array("SET", "key", "value"));
@@ -379,7 +404,7 @@ public class TestRedisOperationExecutor {
 
     @Test
     public void testDelHash() {
-        assertCommandOK(array("hmset","h", "a", "v1", "b", "v2"));
+        assertCommandOK(array("hmset", "h", "a", "v1", "b", "v2"));
         assertCommandEquals(1, array("del", "h"));
         assertCommandArrayEquals(nullArray(), array("hmget", "h", "a"));
     }