├── .editorconfig ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── shard.yml ├── spec ├── mini_redis │ └── pool_spec.cr ├── mini_redis_spec.cr └── spec_helper.cr └── src ├── mini_redis.cr └── mini_redis ├── errors.cr ├── pool.cr └── value.cr /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.cr] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 2 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /lib/ 3 | /bin/ 4 | /.shards/ 5 | *.dwarf 6 | 7 | # Libraries don't need dependency lock 8 | # Dependencies will be locked in application that uses them 9 | /shard.lock 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: crystal 2 | services: 3 | - redis-server 4 | script: 5 | - env REDIS_URL=redis://localhost:6379 crystal spec 6 | - crystal docs 7 | deploy: 8 | provider: pages 9 | keep_history: true 10 | skip_cleanup: true 11 | github_token: $GITHUB_TOKEN 12 | on: 13 | branch: master 14 | local_dir: docs 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 Vlad Faust 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MiniRedis 2 | 3 | [![Built with Crystal](https://img.shields.io/badge/built%20with-crystal-000000.svg?style=flat-square)](https://crystal-lang.org/) 4 | [![Build status](https://img.shields.io/travis/com/vladfaust/mini_redis/master.svg?style=flat-square)](https://travis-ci.com/vladfaust/mini_redis) 5 | [![API Docs](https://img.shields.io/badge/api_docs-online-brightgreen.svg?style=flat-square)](https://github.vladfaust.com/mini_redis) 6 | [![Releases](https://img.shields.io/github/release/vladfaust/mini_redis.svg?style=flat-square)](https://github.com/vladfaust/mini_redis/releases) 7 | [![Awesome](https://awesome.re/badge-flat2.svg)](https://github.com/veelenga/awesome-crystal) 8 | [![vladfaust.com](https://img.shields.io/badge/style-.com-lightgrey.svg?longCache=true&style=flat-square&label=vladfaust&colorB=0a83d8)](https://vladfaust.com) 9 | [![Patrons count](https://img.shields.io/badge/dynamic/json.svg?label=patrons&url=https://www.patreon.com/api/user/11296360&query=$.included[0].attributes.patron_count&style=flat-square&colorB=red&maxAge=86400)](https://www.patreon.com/vladfaust) 10 | [![Gitter chat](https://img.shields.io/badge/chat%20on-gitter-green.svg?colorB=ED1965&logo=gitter&style=flat-square)](https://gitter.im/vladfaust/Lobby) 11 | 12 | A light-weight Redis client for [Crystal](https://crystal-lang.org/). 13 | 14 | [![Become Patron](https://vladfaust.com/img/patreon-small.svg)](https://www.patreon.com/vladfaust) 15 | 16 | ## About 17 | 18 | MiniRedis is a light-weight low-level alternative to existing Redis client implementations. 19 | 20 | In comparison with [crystal-redis](https://github.com/stefanwille/crystal-redis), MiniRedis has lesser memory consumption, built-in logging and first-class support for raw bytes. It also doesn't need to be updated with every Redis release. 21 | 22 | On the other hand, MiniRedis doesn't have commands API (i.e. instead of `redis.ping` you should write `redis.send("PING")`). However, such a low-level interface terminates the dependency on the third-party client maintainer (i.e. me), which makes it a perfect fit to use within a shard. 23 | 24 | You can always find the actual Redis commands API at . 25 | 26 | ### Benchmarks 27 | 28 | Benchmarks code can be found at . 29 | These are recent results of comparison MiniRedis with [crystal-redis](https://github.com/stefanwille/crystal-redis). 30 | 31 | #### `send` benchmarks 32 | 33 | ```sh 34 | > env REDIS_URL=redis://localhost:6379/1 crystal src/send.cr --release 35 | mini_redis 13.4k ( 74.62µs) (± 2.50%) 32 B/op fastest 36 | crystal-redis 13.36k ( 74.83µs) (± 2.97%) 144 B/op 1.00× slower 37 | ``` 38 | 39 | **Conclusion:** `mini_redis` is more memory-efficient. 40 | 41 | #### Pipeline mode benchmarks 42 | 43 | 1 million pipelined `send`s, average from 30 times repeats: 44 | 45 | ```sh 46 | > env REDIS_URL=redis://localhost:6379/1 crystal src/pipeline.cr --release 47 | mini_redis 914.569ms 1.093M ops/s 48 | crystal-redis 908.182ms 1.101M ops/s 49 | ``` 50 | 51 | **Conclusion:** `mini_redis` has almost the same speed as `crystal-redis`. 52 | 53 | ## Installation 54 | 55 | 1. Add the dependency to your `shard.yml`: 56 | 57 | ```yaml 58 | dependencies: 59 | mini_redis: 60 | github: vladfaust/mini_redis 61 | version: ~> 0.2.0 62 | ``` 63 | 64 | 2. Run `shards install` 65 | 66 | This shard follows [Semantic Versioning v2.0.0](http://semver.org/), so check [releases](https://github.com/vladfaust/timer.cr/releases) and change the `version` accordingly. Note that until Crystal is officially released, this shard would be in beta state (`0.*.*`), with every **minor** release considered breaking. For example, `0.1.0` → `0.2.0` is breaking and `0.1.0` → `0.1.1` is not. 67 | 68 | ## Usage 69 | 70 | ```crystal 71 | require "mini_redis" 72 | 73 | redis = MiniRedis.new 74 | 75 | # MiniRedis responses wrap `Int64 | String | Bytes | Nil | Array(Value)` values, 76 | # which map to `Integer`, `Simple String`, `Bulk String`, `Nil` and `Array` Redis values 77 | 78 | # SET command returns `Simple String`, which is `String` in Crystal 79 | pp redis.send("SET", "foo", "bar").raw.as(String) # => "OK" 80 | 81 | # GET command returns `Bulk String`, which is `Bytes` in Crystal 82 | bytes = redis.send("GET", "foo").raw.as(Bytes) 83 | pp String.new(bytes) # => "bar" 84 | 85 | # Bytes command payloads are also supported 86 | redis.send("set", "foo".to_slice, "bar".to_slice) 87 | ``` 88 | 89 | ### Pipelining 90 | 91 | ```crystal 92 | response = redis.pipeline do |pipe| 93 | # WARNING: Accessing the `.send` return value 94 | # within the pipe block would crash the program! 95 | pipe.send("SET", "foo", "bar") 96 | end 97 | 98 | pp typeof(response) # => [MiniRedis::Value(@raw="OK")] 99 | ``` 100 | 101 | ### Transactions 102 | 103 | ```crystal 104 | response = redis.transaction do |tx| 105 | pp tx.send("SET", "foo", "bar").raw.as(String) # => "QUEUED" 106 | end 107 | 108 | pp typeof(response) # => MiniRedis::Value(@raw=[MiniRedis::Value(@raw="OK")]) 109 | ``` 110 | 111 | ### Connection pool 112 | 113 | ```crystal 114 | pool = MiniRedis::Pool.new 115 | 116 | response = pool.get do |redis| 117 | # Redis is MiniRedis instance, can do anything 118 | redis.send("PING") 119 | end 120 | 121 | # Return value equals to the block's 122 | pp response.raw.as(String) # => "PONG" 123 | 124 | conn = pool.get 125 | pp conn.send("PING").raw.as(String) # => "PONG" 126 | pool.release(conn) # Do not forget to put it back! 127 | ``` 128 | 129 | ## Development 130 | 131 | `env REDIS_URL=redis://localhost:6379 crystal spec` and you're good to go. 132 | 133 | ## Contributing 134 | 135 | 1. Fork it () 136 | 2. Create your feature branch (`git checkout -b my-new-feature`) 137 | 3. Commit your changes (`git commit -am 'feat: new feature'`) using [angular-style commits](https://docs.onyxframework.org/contributing/commit-style) 138 | 4. Push to the branch (`git push origin my-new-feature`) 139 | 5. Create a new Pull Request 140 | 141 | ## Contributors 142 | 143 | - [Vlad Faust](https://github.com/vladfaust) - creator and maintainer 144 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: mini_redis 2 | version: 0.2.3 3 | 4 | authors: 5 | - Vlad Faust 6 | 7 | crystal: 0.29.0 8 | 9 | license: MIT 10 | -------------------------------------------------------------------------------- /spec/mini_redis/pool_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe MiniRedis::Pool do 4 | pool = MiniRedis::Pool.new(URI.parse(ENV["REDIS_URL"]), logger: Logger.new(STDOUT)) 5 | 6 | it do 7 | channel = Channel(MiniRedis::Value).new(2) 8 | 9 | 2.times do 10 | spawn do 11 | channel.send(pool.get do |redis| 12 | redis.send("PING", 1) 13 | end) 14 | end 15 | end 16 | 17 | until channel.full? 18 | sleep(0.01) 19 | end 20 | 21 | channel.receive.should eq channel.receive 22 | pool.size.should eq 2 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/mini_redis_spec.cr: -------------------------------------------------------------------------------- 1 | require "./spec_helper" 2 | 3 | describe MiniRedis do 4 | redis = MiniRedis.new(uri: URI.parse(ENV["REDIS_URL"]), logger: Logger.new(STDOUT)) 5 | 6 | describe "#send" do 7 | it do 8 | redis.send("PING").raw.as(String).should eq "PONG" 9 | redis.send("SET", "foo", "bar".to_slice).raw.as(String).should eq "OK" 10 | end 11 | 12 | describe "with compound args" do 13 | it do 14 | slice = Bytes[1, 2, 3] 15 | redis.send("SET", {"foo", slice, 42}, "bar").raw.as(String).should eq "OK" 16 | String.new(redis.send("GET", "foo\x01\x02\x0342").raw.as(Bytes)).should eq "bar" 17 | end 18 | 19 | describe "with negative numbers" do 20 | it do 21 | redis.send("SET", {"foo", -40}, "bar").raw.as(String).should eq "OK" 22 | String.new(redis.send("GET", "foo-40").raw.as(Bytes)).should eq "bar" 23 | end 24 | end 25 | 26 | describe "with zeros" do 27 | it do 28 | redis.send("SET", {"foo", 0}, "bar").raw.as(String).should eq "OK" 29 | String.new(redis.send("GET", "foo0").raw.as(Bytes)).should eq "bar" 30 | end 31 | end 32 | 33 | describe "with floats" do 34 | it do 35 | redis.send("SET", {"foo", 42.33810}, "bar").raw.as(String).should eq "OK" 36 | String.new(redis.send("GET", "foo42.3381").raw.as(Bytes)).should eq "bar" 37 | end 38 | end 39 | end 40 | end 41 | 42 | describe "#pipeline" do 43 | it do 44 | response = redis.pipeline do |pipe| 45 | pipe.send("SET", "foo", "baz") 46 | pipe.send({"GET", "foo"}) 47 | end 48 | 49 | response.should eq [MiniRedis::Value.new("OK"), MiniRedis::Value.new("baz".to_slice)] 50 | end 51 | end 52 | 53 | describe "#transaction" do 54 | it do 55 | response = redis.transaction do |tx| 56 | tx.send("SET", "foo", "qux".to_slice) 57 | tx.send("GET", "foo") 58 | end 59 | 60 | response.raw.should eq [MiniRedis::Value.new("OK"), MiniRedis::Value.new("qux".to_slice)] 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/mini_redis" 3 | -------------------------------------------------------------------------------- /src/mini_redis.cr: -------------------------------------------------------------------------------- 1 | require "socket" 2 | require "logger" 3 | require "colorize" 4 | 5 | require "./mini_redis/*" 6 | 7 | # A light-weight low-level Redis client. 8 | class MiniRedis 9 | # Initialize with Redis *uri* and optional *logger*. 10 | # The *logger* would log outcoming commands with *logger_severity* level. 11 | # 12 | # ``` 13 | # redis = MiniRedis.new(URI.parse(ENV["REDIS_URL"]), logger: Logger.new(STDOUT)) 14 | # ``` 15 | def self.new( 16 | uri : URI = URI.parse("redis://localhost:6379"), 17 | logger : Logger? = nil, 18 | logger_severity : Logger::Severity = Logger::Severity::INFO, 19 | dns_timeout : Time::Span? = 5.seconds, 20 | connect_timeout : Time::Span? = 5.seconds, 21 | read_timeout : Time::Span? = nil, 22 | write_timeout : Time::Span? = 5.seconds 23 | ) 24 | socket = TCPSocket.new( 25 | host: uri.host.not_nil!, 26 | port: uri.port.not_nil!, 27 | dns_timeout: dns_timeout, 28 | connect_timeout: connect_timeout 29 | ) 30 | 31 | socket.sync = false 32 | socket.read_timeout = read_timeout 33 | socket.write_timeout = write_timeout 34 | 35 | new(socket, logger, logger_severity) 36 | end 37 | 38 | def_equals_and_hash socket 39 | 40 | # Initialize with raw Crystal `Socket` and optional *logger*. 41 | # The *logger* would log outcoming commands with *logger_severity* level. 42 | def initialize( 43 | @socket : Socket, 44 | @logger : Logger? = nil, 45 | @logger_severity : Logger::Severity = Logger::Severity::INFO 46 | ) 47 | end 48 | 49 | # :nodoc: 50 | def finalize 51 | close 52 | end 53 | 54 | # Close the underlying socket. 55 | def close 56 | @socket.close 57 | end 58 | 59 | # The underlying socket. 60 | getter socket 61 | 62 | # Whether is current connection in transaction mode. See `#transaction`. 63 | getter? transaction : Bool = false 64 | 65 | # Whether is current connection in pipeline mode. See `#pipeline`. 66 | getter? pipeline : Bool = false 67 | 68 | # The logger which logs commands. 69 | property logger : Logger? 70 | 71 | # The `#logger` severity. 72 | property logger_severity : Logger::Severity 73 | 74 | # Send the *commands* marshalled according to the [Redis Protocol Specification](https://redis.io/topics/protocol). 75 | # 76 | # ``` 77 | # redis.send("PING") # MiniRedis::Value(@raw="PONG") 78 | # redis.send("GET", "foo") # MiniRedis::Value(@raw=Bytes) 79 | # ``` 80 | def send(commands : Enumerable) : Value 81 | log(commands) 82 | 83 | @socket << "*" << commands.size << "\r\n" 84 | 85 | commands.each do |command| 86 | marshal(command, @socket) 87 | end 88 | 89 | send_impl 90 | end 91 | 92 | # ditto 93 | def send(*commands) : Value | Nil 94 | send(commands) 95 | end 96 | 97 | @queued = 0 98 | 99 | # Yield `self`, accumulate requests and then flush them all in one moment. 100 | # See [Pipelining docs](https://redis.io/topics/pipelining). 101 | # 102 | # It returns an `Array` of `Value`s. 103 | # 104 | # ``` 105 | # response = redis.pipeline do |pipe| 106 | # # WARNING: Do not try to access its return value while 107 | # # within the pipeline block. See the explaination below 108 | # pipe.send("PING") 109 | # end 110 | # 111 | # pp response # => Array([MiniRedis::Value(@raw="PONG")]) 112 | # ``` 113 | # 114 | # WARNING: `#send` returns an `uninitalized Value` when in pipeline mode. 115 | # Trying to access it would crash the program. Use `#pipeline?` if you want to be sure. 116 | # 117 | # ``` 118 | # # When you're not sure about the `redis` type... 119 | # 120 | # # Wrong ✖️ 121 | # puts redis.send("PING") # May crash with `Invalid memory access` 122 | # 123 | # # Right ✔️ 124 | # unless redis.pipeline? 125 | # puts redis.send("PING") 126 | # end 127 | # ``` 128 | def pipeline(&block : self ->) : Array(Value) 129 | @pipeline = true 130 | @queued = 0 131 | 132 | yield(self) 133 | 134 | @socket.flush 135 | @pipeline = false 136 | 137 | @queued.times.reduce(Array(Value).new(@queued)) do |ary| 138 | ary << receive 139 | end 140 | end 141 | 142 | # Send `"MULTI"` command, yield `self` and then send `"EXEC"` command. 143 | # See [Transactions docs](https://redis.io/topics/transactions). 144 | # 145 | # It returns a `Value` containing an `Array` of `Value`s. 146 | # 147 | # ``` 148 | # response = redis.transaction do |tx| 149 | # pp tx.send("SET", "foo", "bar") # => MiniRedis::Value(@raw="QUEUED") 150 | # end 151 | # 152 | # pp response # => MiniRedis::Value(@raw=[MiniRedis::Value(@raw=Bytes)]) 153 | # ``` 154 | def transaction(&block : self ->) : Value 155 | send("MULTI") 156 | 157 | @transaction = true 158 | yield(self) 159 | @transaction = false 160 | 161 | send("EXEC") 162 | end 163 | 164 | # Internal `#send` implementation. 165 | protected def send_impl : Value 166 | unless @pipeline 167 | @socket.flush 168 | end 169 | 170 | if @pipeline 171 | @queued += 1 172 | value = uninitialized Value 173 | elsif @transaction 174 | value = receive(skip_queued: true) 175 | else 176 | value = receive 177 | end 178 | 179 | return value 180 | end 181 | 182 | # :nodoc: 183 | QUEUED_BYTESIZE = "QUEUED\r\n".bytesize 184 | 185 | # Read a response. Blocks until read. The response is cast to a Crystal type 186 | # according to the [Redis Protocol Specification](https://redis.io/topics/protocol). 187 | # For more information on types, see `Value`. 188 | # 189 | # In case of error (`-` byte), a `Error` is raised. 190 | # 191 | # There is an optional *skip_queue* argument, which is used in `Transaction` mode -- 192 | # it skips reading `"QUEUED"` strings, which improves the performance. 193 | protected def receive(*, skip_queued = false) : Value 194 | type = @socket.read_char 195 | 196 | case type 197 | when '-' 198 | raise Error.new(read_line) 199 | when ':' 200 | return Value.new(read_line.to_i64) 201 | when '$' 202 | length = read_line.to_i32 203 | return Value.new(nil) if length == -1 204 | 205 | bytes = Bytes.new(length) 206 | @socket.read_fully(bytes) 207 | 208 | @socket.skip(2) 209 | return Value.new(bytes) 210 | when '+' 211 | if skip_queued 212 | @socket.skip(QUEUED_BYTESIZE) 213 | return Value.new("QUEUED") 214 | else 215 | return Value.new(read_line) 216 | end 217 | when '*' 218 | size = read_line.to_i 219 | 220 | if size == -1 221 | return Value.new(nil) 222 | else 223 | return size.times.reduce(Value.new(Array(Value).new(size))) do |val, _| 224 | val.raw.as(Array).push(receive) 225 | val 226 | end 227 | end 228 | else 229 | raise Error.new("Received invalid type string '#{type}'") 230 | end 231 | end 232 | 233 | protected def read_line : String 234 | @socket.gets || raise ConnectionError.new("The Redis server has closed the connection") 235 | end 236 | 237 | protected def marshal(arg : Int, io) : Nil 238 | marshal(arg.to_s, io) 239 | end 240 | 241 | protected def marshal(arg : Float, io) : Nil 242 | raise ArgumentError.new("Bulk value must be finite") unless arg.finite? 243 | marshal(arg.to_s, io) 244 | end 245 | 246 | protected def marshal(arg : String | Char, io) : Nil 247 | io << "$" << arg.bytesize << "\r\n" << arg << "\r\n" 248 | end 249 | 250 | protected def marshal(arg : Bytes, io) : Nil 251 | io << "$" << arg.bytesize << "\r\n" 252 | io.write(arg) 253 | io << "\r\n" 254 | end 255 | 256 | protected def marshal(args : Enumerable(String | Char | Bytes | Int8 | Int16 | Int32 | Int64 | UInt8 | UInt16 | UInt32 | UInt64 | Float32 | Float64), io) : Nil 257 | io << "$" << args.sum do |a| 258 | if a.is_a?(Int) 259 | (a.abs < 10 ? 1 : Math.log10(a.abs + (a % 10 == 0 ? 1 : 0)).ceil.to_i32) + (a < 0 ? 1 : 0) 260 | elsif a.is_a?(Float) 261 | raise ArgumentError.new("Bulk value must be finite") unless a.finite? 262 | a.to_s.bytesize 263 | else 264 | a.bytesize 265 | end 266 | end << "\r\n" 267 | 268 | args.each do |arg| 269 | if arg.is_a?(Bytes) 270 | io.write(arg) 271 | else 272 | io << arg 273 | end 274 | end 275 | 276 | io << "\r\n" 277 | end 278 | 279 | protected def marshal(arg : Nil, io) : Nil 280 | io << "$-1\r\n" 281 | end 282 | 283 | protected def log(commands : Enumerable) 284 | @logger.try &.log(@logger_severity) do 285 | String.build do |builder| 286 | builder << "[redis] " 287 | first = true 288 | commands.each do |cmd| 289 | builder << ' ' unless first; first = false 290 | decorate_command(cmd, builder) 291 | end 292 | end.colorize(:red).to_s 293 | end 294 | end 295 | 296 | protected def decorate_command(cmd, builder) 297 | case cmd 298 | when Bytes 299 | cmd.each do |b| 300 | builder << '\\' << 'x' 301 | builder.write_byte(to_hex(b >> 4)) 302 | builder.write_byte(to_hex(b & 0x0f)) 303 | end 304 | when String, Char, Int, Float then builder << cmd 305 | when Enumerable then cmd.each { |c| decorate_command(c, builder) } 306 | else 307 | raise "BUG: Unhandled cmd class #{cmd.class}" 308 | end 309 | end 310 | 311 | @[AlwaysInline] 312 | protected def to_hex(c) 313 | ((c < 10 ? 48_u8 : 87_u8) + c) 314 | end 315 | end 316 | -------------------------------------------------------------------------------- /src/mini_redis/errors.cr: -------------------------------------------------------------------------------- 1 | class MiniRedis 2 | # A error which is raised in case when a error is read from Redis response. 3 | class Error < Exception 4 | end 5 | 6 | # A error which is raised when something's wrong with Redis connection. 7 | class ConnectionError < Exception 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /src/mini_redis/pool.cr: -------------------------------------------------------------------------------- 1 | class MiniRedis 2 | # A `MiniRedis` connection pool. It has dynamic `#capacity` and `#block` values. 3 | class Pool 4 | # Initialize a new pool with `#block` initializing a 5 | # `MiniRedis` client from the *uri*. 6 | def self.new( 7 | uri : URI = URI.parse("redis://localhost:6379"), 8 | capacity : Int32 = Int32::MAX, 9 | initial_size : Int32 = 0, 10 | logger : Logger? = nil, 11 | logger_severity : Logger::Severity = Logger::Severity::INFO, 12 | dns_timeout : Time::Span? = 5.seconds, 13 | connect_timeout : Time::Span? = 5.seconds, 14 | read_timeout : Time::Span? = nil, 15 | write_timeout : Time::Span? = 5.seconds 16 | ) 17 | new(capacity, initial_size) do 18 | MiniRedis.new( 19 | uri: uri, 20 | logger: logger, 21 | logger_severity: logger_severity, 22 | dns_timeout: dns_timeout, 23 | connect_timeout: connect_timeout, 24 | read_timeout: read_timeout, 25 | write_timeout: write_timeout, 26 | ) 27 | end 28 | end 29 | 30 | # The pool's capacity. Can be changed after the pool is initialized. 31 | property capacity : Int32 32 | 33 | # The pool's block to call to initialize a new `MiniRedis` instance. 34 | # Can be changed after the pool is initialized. 35 | property block : Proc(MiniRedis) 36 | 37 | # The number of free clients in this pool. 38 | def free 39 | @free.size 40 | end 41 | 42 | # The number of clients in this pool currently being used. 43 | def used 44 | @used.size 45 | end 46 | 47 | # The total size of this pool (`#free` plus `#used`). 48 | def size 49 | free + used 50 | end 51 | 52 | @free = Deque(MiniRedis).new 53 | @used = Set(MiniRedis).new 54 | 55 | def initialize(@capacity : Int32 = Int32::MAX, initial_size : Int32 = 0, &@block : -> MiniRedis) 56 | initial_size.times do 57 | @free.push(@block.call) 58 | end 59 | end 60 | 61 | # Yield a free `MiniRedis` client. 62 | # Blocks until one is available, raises `TimeoutError` on optional *timeout*. 63 | # Calls `#release` after yield. 64 | def get(timeout : Time::Span? = nil, &block : MiniRedis ->) 65 | redis = get(timeout) 66 | result = yield(redis) 67 | result 68 | ensure 69 | release(redis) if redis 70 | end 71 | 72 | # Return a free `MiniRedis` client. 73 | # Blocks until one is available, raises `TimeoutError` on optional *timeout*. 74 | # 75 | # NOTE: Do not forget to `#release` the client afterwards! 76 | def get(timeout : Time::Span? = nil) : MiniRedis 77 | if redis = @free.shift? 78 | return redis 79 | else 80 | if @capacity.nil? || @used.size < @capacity.not_nil! 81 | redis = @block.call 82 | @used.add(redis) 83 | return redis 84 | else 85 | if timeout 86 | started_at = Time.monotonic 87 | 88 | loop do 89 | sleep(0.01) 90 | 91 | if redis = @free.shift? 92 | return redis 93 | elsif Time.monotonic - started_at >= timeout 94 | raise TimeoutError.new 95 | end 96 | end 97 | else 98 | loop do 99 | sleep(0.01) 100 | 101 | if redis = @free.shift? 102 | return redis 103 | end 104 | end 105 | end 106 | end 107 | end 108 | end 109 | 110 | # Put the *redis* client back into the pool. 111 | def release(redis : MiniRedis) : Nil 112 | @used.delete(redis) 113 | @free.push(redis) 114 | end 115 | 116 | # Could be raised when a *timeout* argument is provided upon `#get` call. 117 | class TimeoutError < Exception 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /src/mini_redis/value.cr: -------------------------------------------------------------------------------- 1 | class MiniRedis 2 | # A Redis value. It's `#raw` value has a type according to [Redis Protocol Specification](https://redis.io/topics/protocol): 3 | # 4 | # ``` 5 | # # Redis type | First byte | Crystal type | 6 | # # ------------- | ---------- | ------------ | 7 | # # Simple String | `+` | `String` | 8 | # # Integer | `:` | `Int64` | 9 | # # Bulk String | `$` | `Bytes` | 10 | # # Array | `*` | `Array` | 11 | # ``` 12 | # 13 | # ``` 14 | # response = redis.transaction do |tx| 15 | # pp tx.send("SET foo bar") # => MiniRedis::Value(@raw="QUEUED") 16 | # end 17 | # 18 | # response = String.new(response.raw.as(Array).first.raw.as(Bytes)) 19 | # pp response # => "bar" 20 | # 21 | # response = redis.send("GET foo") 22 | # response = String.new(response.raw.as(Bytes)) 23 | # pp response # => "bar" 24 | # ``` 25 | # 26 | # Reminder — do not try to directly print a `MiniRedis#send` response when in 27 | # pipeline mode! See `MiniRedis#pipeline` docs. 28 | struct Value 29 | getter raw 30 | 31 | def initialize(@raw : Int64 | String | Bytes | Nil | Array(Value)) 32 | end 33 | end 34 | end 35 | --------------------------------------------------------------------------------