├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── .golangci.yaml ├── LICENSE ├── Makefile ├── README.md ├── bench ├── benchstat.txt ├── get_test.go ├── go.mod └── go.sum ├── client.go ├── client_test.go ├── conn.go ├── go.mod ├── go.sum ├── pipeline.go ├── pubsub.go ├── redcache ├── cache.go └── cache_test.go └── redtest └── server.go /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: [push] 3 | jobs: 4 | make: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - name: install redis 9 | run: sudo apt-get install redis-server 10 | - uses: actions/setup-go@v4 11 | with: 12 | go-version: "^1.20" 13 | - name: test & lint 14 | # run: make test lint 15 | run: make test 16 | - name: Send coverage 17 | if: always() 18 | env: 19 | COVERALLS_TOKEN: ${{ secrets.COVERALLS_TOKEN }} 20 | run: make send-cover 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **.test 2 | coverage.out 3 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | linters: 2 | disable: 3 | - errcheck 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL = /bin/bash 2 | .ONESHELL: 3 | 4 | .PHONY: test 5 | 6 | test: 7 | go test -timeout=3m -race . -coverprofile=coverage.out -covermode=atomic 8 | 9 | send-cover: 10 | go install github.com/mattn/goveralls@v0.0.12 11 | goveralls -coverprofile=coverage.out -service=github 12 | 13 | .PHONY: lint 14 | lint: 15 | go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.53.3 16 | ~/go/bin/golangci-lint run 17 | 18 | .PHONY: doctoc 19 | doctoc: 20 | doctoc --title "**Table of Contents**" --github README.md 21 | 22 | .PHONY: gen-bench 23 | gen-bench: 24 | pushd bench 25 | libs=(go-redis redigo redjet rueidis); 26 | for lib in $${libs[@]}; do 27 | echo "Benchmarking $$lib"; 28 | go test -bench=. -count=10 -run=. -lib=$$lib \ 29 | -memprofile=/tmp/$$lib.mem.out -cpuprofile=/tmp/$$lib.cpu.out | tee /tmp/$$lib.bench.out 30 | echo "Finished benchmarking $$lib"; 31 | done 32 | benchstat -table .fullname -row=unit -col .file \ 33 | redjet=/tmp/redjet.bench.out redigo=/tmp/redigo.bench.out \ 34 | go-redis=/tmp/go-redis.bench.out rueidis=/tmp/rueidis.bench.out \ 35 | > benchstat.txt -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redjet 2 | [![Go Reference](https://pkg.go.dev/badge/github.com/coder/redjet.svg)](https://pkg.go.dev/github.com/coder/redjet) 3 | ![ci](https://github.com/coder/redjet/actions/workflows/ci.yaml/badge.svg) 4 | [![Coverage Status](https://coveralls.io/repos/github/coder/redjet/badge.svg)](https://coveralls.io/github/coder/redjet) 5 | [![Go Report Card](https://goreportcard.com/badge/github.com/coder/redjet)](https://goreportcard.com/report/github.com/coder/redjet) 6 | 7 | 8 | 9 | redjet is a high-performance Go library for Redis. Its hallmark feature is 10 | a low-allocation, streaming API. See the [benchmarks](#benchmarks) section for 11 | more details. 12 | 13 | Unlike [redigo](https://github.com/gomodule/redigo) and [go-redis](https://github.com/redis/go-redis), redjet does not provide a function for every 14 | Redis command. Instead, it offers a generic interface that supports [all commands 15 | and options](https://redis.io/commands/). While this approach has less 16 | type-safety, it provides forward compatibility with new Redis features. 17 | 18 | In the aim of both performance and ease-of-use, redjet attempts to provide 19 | an API that closely resembles the protocol. For example, the `Command` method 20 | is really a Pipeline of size 1. 21 | 22 | 23 | 24 | **Table of Contents** 25 | 26 | - [redjet](#redjet) 27 | - [Basic Usage](#basic-usage) 28 | - [Streaming](#streaming) 29 | - [Pipelining](#pipelining) 30 | - [PubSub](#pubsub) 31 | - [JSON](#json) 32 | - [Connection Pooling](#connection-pooling) 33 | - [Benchmarks](#benchmarks) 34 | - [Limitations](#limitations) 35 | 36 | 37 | 38 | ## Basic Usage 39 | 40 | Install: 41 | 42 | ```bash 43 | go get github.com/coder/redjet@latest 44 | ``` 45 | 46 | For the most part, you can interact with Redis using a familiar interface: 47 | 48 | ```go 49 | package main 50 | 51 | import ( 52 | "context" 53 | "fmt" 54 | "log" 55 | 56 | "github.com/coder/redjet" 57 | ) 58 | 59 | func main() { 60 | client := redjet.New("localhost:6379") 61 | ctx := context.Background() 62 | 63 | err := client.Command(ctx, "SET", "foo", "bar").Ok() 64 | // check error 65 | 66 | got, err := client.Command(ctx, "GET", "foo").Bytes() 67 | // check error 68 | // got == []byte("bar") 69 | } 70 | ``` 71 | 72 | ## Streaming 73 | 74 | To minimize allocations, call `(*Pipeline).WriteTo` instead of `(*Pipeline).Bytes`. 75 | `WriteTo` streams the response directly to an `io.Writer` such as a file or HTTP response. 76 | 77 | For example: 78 | 79 | ```go 80 | _, err := client.Command(ctx, "GET", "big-object").WriteTo(os.Stdout) 81 | // check error 82 | ``` 83 | 84 | Similarly, you can pass in a value that implements `redjet.LenReader` to 85 | `Command` to stream larger values into Redis. Unfortunately, the API 86 | cannot accept a regular `io.Reader` because bulk string messages in 87 | the Redis protocol are length-prefixed. 88 | 89 | Here's an example of streaming a large file into Redis: 90 | 91 | ```go 92 | bigFile, err := os.Open("bigfile.txt") 93 | // check error 94 | defer bigFile.Close() 95 | 96 | stat, err := bigFile.Stat() 97 | // check error 98 | 99 | err = client.Command( 100 | ctx, "SET", "bigfile", 101 | redjet.NewLenReader(bigFile, stat.Size()), 102 | ).Ok() 103 | // check error 104 | ``` 105 | 106 | 107 | If you have no way of knowing the size of your blob in advance and still 108 | want to avoid large allocations, you may chunk a stream into Redis using repeated [`APPEND`](https://redis.io/commands/append/) commands. 109 | 110 | ## Pipelining 111 | 112 | `redjet` supports [pipelining](https://redis.io/docs/manual/pipelining/) via the `(*Client).Pipeline` method. This method accepts a `Pipeline`, potentially that of a previous, open command. 113 | 114 | ```go 115 | // Set foo0, foo1, ..., foo99 to "bar", and confirm that each succeeded. 116 | // 117 | // This entire example only takes one round-trip to Redis! 118 | var p *Pipeline 119 | for i := 0; i < 100; i++ { 120 | p = client.Pipeline(p, "SET", fmt.Sprintf("foo%d", i), "bar") 121 | } 122 | 123 | for r.Next() { 124 | if err := p.Ok(); err != nil { 125 | log.Fatal(err) 126 | } 127 | } 128 | p.Close() // allow the underlying connection to be reused. 129 | ``` 130 | 131 | ## PubSub 132 | 133 | redjet suports PubSub via the `NextSubMessage` method. For example: 134 | 135 | ```go 136 | // Subscribe to a channel 137 | sub := client.Command(ctx, "SUBSCRIBE", "my-channel") 138 | sub.NextSubMessage() // ignore the first message, which is a confirmation of the subscription 139 | 140 | // Publish a message to the channel 141 | n, err := client.Command(ctx, "PUBLISH", "my-channel", "hello world").Int() 142 | // check error 143 | // n == 1, since there is one subscriber 144 | 145 | // Receive the message 146 | sub.NextSubMessage() 147 | // sub.Payload == "hello world" 148 | // sub.Channel == "my-channel" 149 | // sub.Type == "message" 150 | ``` 151 | 152 | Note that `NextSubMessage` will block until a message is received. To interrupt the subscription, cancel the context passed to `Command`. 153 | 154 | Once a connection enters subscribe mode, the internal pool does not 155 | re-use it. 156 | 157 | It is possible to subscribe to a channel in a performant, low-allocation way 158 | via the public API. NextSubMessage is just a convenience method. 159 | 160 | ## JSON 161 | 162 | `redjet` supports convenient JSON encoding and decoding via the `(*Pipeline).JSON` method. For example: 163 | 164 | ```go 165 | type Person struct { 166 | Name string `json:"name"` 167 | Age int `json:"age"` 168 | } 169 | 170 | // Set a person 171 | // Unknown argument types are automatically encoded to JSON. 172 | err := client.Command(ctx, "SET", "person", Person{ 173 | Name: "Alice", 174 | Age: 30, 175 | }).Ok() 176 | // check error 177 | 178 | // Get a person 179 | var p Person 180 | client.Command(ctx, "GET", "person").JSON(&p) 181 | // check error 182 | 183 | // p == Person{Name: "Alice", Age: 30} 184 | ``` 185 | 186 | ## Connection Pooling 187 | 188 | Redjet provides automatic connection pooling. Configuration knobs exist 189 | within the `Client` struct that may be changed before any Commands are 190 | issued. 191 | 192 | If you want synchronous command execution over the same connection, 193 | use the `Pipeline` method and consume the Pipeline after each call to `Pipeline`. Storing a long-lived `Pipeline` 194 | offers the same functionality as storing a long-lived connection. 195 | 196 | ## Benchmarks 197 | 198 | On a pure throughput basis, redjet will perform similarly to redigo and go-redis. 199 | But, since redjet doesn't allocate memory for the entire response object, it 200 | consumes far less resources when handling large responses. 201 | 202 | Here are some benchmarks (reproducible via `make gen-bench`) to illustrate: 203 | 204 | ``` 205 | .fullname: Get/1_B-10 206 | │ redjet │ redigo │ go-redis │ rueidis │ 207 | │ sec/op │ sec/op vs base │ sec/op vs base │ sec/op vs base │ 208 | 908.2n ± 2% 962.4n ± 1% +5.97% (p=0.000 n=10) 913.8n ± 3% ~ (p=0.280 n=10) 1045.0n ± 1% +15.06% (p=0.000 n=10) 209 | 210 | │ redjet │ redigo │ go-redis │ rueidis │ 211 | │ B/s │ B/s vs base │ B/s vs base │ B/s vs base │ 212 | 1074.2Ki ± 2% 1015.6Ki ± 1% -5.45% (p=0.000 n=10) 1069.3Ki ± 2% ~ (p=0.413 n=10) 937.5Ki ± 1% -12.73% (p=0.000 n=10) 213 | 214 | │ redjet │ redigo │ go-redis │ rueidis │ 215 | │ B/op │ B/op vs base │ B/op vs base │ B/op vs base │ 216 | 0.00 ± 0% 41.00 ± 0% ? (p=0.000 n=10) 275.50 ± 2% ? (p=0.000 n=10) 249.00 ± 0% ? (p=0.000 n=10) 217 | 218 | │ redjet │ redigo │ go-redis │ rueidis │ 219 | │ allocs/op │ allocs/op vs base │ allocs/op vs base │ allocs/op vs base │ 220 | 0.000 ± 0% 3.000 ± 0% ? (p=0.000 n=10) 4.000 ± 0% ? (p=0.000 n=10) 2.000 ± 0% ? (p=0.000 n=10) 221 | 222 | .fullname: Get/1.0_kB-10 223 | │ redjet │ redigo │ go-redis │ rueidis │ 224 | │ sec/op │ sec/op vs base │ sec/op vs base │ sec/op vs base │ 225 | 1.302µ ± 2% 1.802µ ± 1% +38.42% (p=0.000 n=10) 1.713µ ± 3% +31.58% (p=0.000 n=10) 1.645µ ± 1% +26.35% (p=0.000 n=10) 226 | 227 | │ redjet │ redigo │ go-redis │ rueidis │ 228 | │ B/s │ B/s vs base │ B/s vs base │ B/s vs base │ 229 | 750.4Mi ± 2% 542.1Mi ± 1% -27.76% (p=0.000 n=10) 570.3Mi ± 3% -24.01% (p=0.000 n=10) 593.8Mi ± 1% -20.87% (p=0.000 n=10) 230 | 231 | │ redjet │ redigo │ go-redis │ rueidis │ 232 | │ B/op │ B/op vs base │ B/op vs base │ B/op vs base │ 233 | 0.000Ki ± 0% 1.039Ki ± 0% ? (p=0.000 n=10) 1.392Ki ± 0% ? (p=0.000 n=10) 1.248Ki ± 1% ? (p=0.000 n=10) 234 | 235 | │ redjet │ redigo │ go-redis │ rueidis │ 236 | │ allocs/op │ allocs/op vs base │ allocs/op vs base │ allocs/op vs base │ 237 | 0.000 ± 0% 3.000 ± 0% ? (p=0.000 n=10) 4.000 ± 0% ? (p=0.000 n=10) 2.000 ± 0% ? (p=0.000 n=10) 238 | 239 | .fullname: Get/1.0_MB-10 240 | │ redjet │ redigo │ go-redis │ rueidis │ 241 | │ sec/op │ sec/op vs base │ sec/op vs base │ sec/op vs base │ 242 | 472.5µ ± 7% 477.3µ ± 2% ~ (p=0.190 n=10) 536.8µ ± 6% +13.61% (p=0.000 n=10) 475.3µ ± 6% ~ (p=0.684 n=10) 243 | 244 | │ redjet │ redigo │ go-redis │ rueidis │ 245 | │ B/s │ B/s vs base │ B/s vs base │ B/s vs base │ 246 | 2.067Gi ± 8% 2.046Gi ± 2% ~ (p=0.190 n=10) 1.819Gi ± 6% -11.98% (p=0.000 n=10) 2.055Gi ± 6% ~ (p=0.684 n=10) 247 | 248 | │ redjet │ redigo │ go-redis │ rueidis │ 249 | │ B/op │ B/op vs base │ B/op vs base │ B/op vs base │ 250 | 51.00 ± 12% 1047849.50 ± 0% +2054506.86% (p=0.000 n=10) 1057005.00 ± 0% +2072458.82% (p=0.000 n=10) 1048808.50 ± 0% +2056387.25% (p=0.000 n=10) 251 | 252 | │ redjet │ redigo │ go-redis │ rueidis │ 253 | │ allocs/op │ allocs/op vs base │ allocs/op vs base │ allocs/op vs base │ 254 | 1.000 ± 0% 3.000 ± 0% +200.00% (p=0.000 n=10) 4.000 ± 0% +300.00% (p=0.000 n=10) 2.000 ± 0% +100.00% (p=0.000 n=10) 255 | 256 | ``` 257 | 258 | ## Limitations 259 | 260 | - redjet does not have convenient support for client side caching. But, the redjet API 261 | is flexible enough that a client could implement it themselves by following the instructions [here](https://redis.io/docs/manual/client-side-caching/#two-connections-mode). 262 | - RESP3 is not supported. Practically, this means that connections aren't 263 | multiplexed, and other Redis libraries may perform better in high-concurrency 264 | scenarios. 265 | - Certain features have not been tested but may still work: 266 | - Redis Streams 267 | - Monitor -------------------------------------------------------------------------------- /bench/benchstat.txt: -------------------------------------------------------------------------------- 1 | .fullname: Get/1_B-10 2 | │ redjet │ redigo │ go-redis │ rueidis │ 3 | │ sec/op │ sec/op vs base │ sec/op vs base │ sec/op vs base │ 4 | 908.2n ± 2% 962.4n ± 1% +5.97% (p=0.000 n=10) 913.8n ± 3% ~ (p=0.280 n=10) 1045.0n ± 1% +15.06% (p=0.000 n=10) 5 | 6 | │ redjet │ redigo │ go-redis │ rueidis │ 7 | │ B/s │ B/s vs base │ B/s vs base │ B/s vs base │ 8 | 1074.2Ki ± 2% 1015.6Ki ± 1% -5.45% (p=0.000 n=10) 1069.3Ki ± 2% ~ (p=0.413 n=10) 937.5Ki ± 1% -12.73% (p=0.000 n=10) 9 | 10 | │ redjet │ redigo │ go-redis │ rueidis │ 11 | │ B/op │ B/op vs base │ B/op vs base │ B/op vs base │ 12 | 0.00 ± 0% 41.00 ± 0% ? (p=0.000 n=10) 275.50 ± 2% ? (p=0.000 n=10) 249.00 ± 0% ? (p=0.000 n=10) 13 | 14 | │ redjet │ redigo │ go-redis │ rueidis │ 15 | │ allocs/op │ allocs/op vs base │ allocs/op vs base │ allocs/op vs base │ 16 | 0.000 ± 0% 3.000 ± 0% ? (p=0.000 n=10) 4.000 ± 0% ? (p=0.000 n=10) 2.000 ± 0% ? (p=0.000 n=10) 17 | 18 | .fullname: Get/1.0_kB-10 19 | │ redjet │ redigo │ go-redis │ rueidis │ 20 | │ sec/op │ sec/op vs base │ sec/op vs base │ sec/op vs base │ 21 | 1.302µ ± 2% 1.802µ ± 1% +38.42% (p=0.000 n=10) 1.713µ ± 3% +31.58% (p=0.000 n=10) 1.645µ ± 1% +26.35% (p=0.000 n=10) 22 | 23 | │ redjet │ redigo │ go-redis │ rueidis │ 24 | │ B/s │ B/s vs base │ B/s vs base │ B/s vs base │ 25 | 750.4Mi ± 2% 542.1Mi ± 1% -27.76% (p=0.000 n=10) 570.3Mi ± 3% -24.01% (p=0.000 n=10) 593.8Mi ± 1% -20.87% (p=0.000 n=10) 26 | 27 | │ redjet │ redigo │ go-redis │ rueidis │ 28 | │ B/op │ B/op vs base │ B/op vs base │ B/op vs base │ 29 | 0.000Ki ± 0% 1.039Ki ± 0% ? (p=0.000 n=10) 1.392Ki ± 0% ? (p=0.000 n=10) 1.248Ki ± 1% ? (p=0.000 n=10) 30 | 31 | │ redjet │ redigo │ go-redis │ rueidis │ 32 | │ allocs/op │ allocs/op vs base │ allocs/op vs base │ allocs/op vs base │ 33 | 0.000 ± 0% 3.000 ± 0% ? (p=0.000 n=10) 4.000 ± 0% ? (p=0.000 n=10) 2.000 ± 0% ? (p=0.000 n=10) 34 | 35 | .fullname: Get/1.0_MB-10 36 | │ redjet │ redigo │ go-redis │ rueidis │ 37 | │ sec/op │ sec/op vs base │ sec/op vs base │ sec/op vs base │ 38 | 472.5µ ± 7% 477.3µ ± 2% ~ (p=0.190 n=10) 536.8µ ± 6% +13.61% (p=0.000 n=10) 475.3µ ± 6% ~ (p=0.684 n=10) 39 | 40 | │ redjet │ redigo │ go-redis │ rueidis │ 41 | │ B/s │ B/s vs base │ B/s vs base │ B/s vs base │ 42 | 2.067Gi ± 8% 2.046Gi ± 2% ~ (p=0.190 n=10) 1.819Gi ± 6% -11.98% (p=0.000 n=10) 2.055Gi ± 6% ~ (p=0.684 n=10) 43 | 44 | │ redjet │ redigo │ go-redis │ rueidis │ 45 | │ B/op │ B/op vs base │ B/op vs base │ B/op vs base │ 46 | 51.00 ± 12% 1047849.50 ± 0% +2054506.86% (p=0.000 n=10) 1057005.00 ± 0% +2072458.82% (p=0.000 n=10) 1048808.50 ± 0% +2056387.25% (p=0.000 n=10) 47 | 48 | │ redjet │ redigo │ go-redis │ rueidis │ 49 | │ allocs/op │ allocs/op vs base │ allocs/op vs base │ allocs/op vs base │ 50 | 1.000 ± 0% 3.000 ± 0% +200.00% (p=0.000 n=10) 4.000 ± 0% +300.00% (p=0.000 n=10) 2.000 ± 0% +100.00% (p=0.000 n=10) 51 | -------------------------------------------------------------------------------- /bench/get_test.go: -------------------------------------------------------------------------------- 1 | package bench 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "flag" 8 | "io" 9 | "net" 10 | "os/exec" 11 | "strings" 12 | "testing" 13 | "time" 14 | 15 | "github.com/coder/redjet" 16 | "github.com/dustin/go-humanize" 17 | redigo "github.com/gomodule/redigo/redis" 18 | goredis "github.com/redis/go-redis/v9" 19 | "github.com/redis/rueidis" 20 | "github.com/stretchr/testify/require" 21 | ) 22 | 23 | type testWriter struct { 24 | prefix string 25 | t testing.TB 26 | } 27 | 28 | func (w *testWriter) Write(p []byte) (int, error) { 29 | sc := bufio.NewScanner(bytes.NewReader(p)) 30 | for sc.Scan() { 31 | w.t.Logf("%s: %s", w.prefix, sc.Text()) 32 | } 33 | return len(p), nil 34 | } 35 | 36 | func startRedisServer(t testing.TB) string { 37 | // We use a TCP port because it has much better performance.. for some 38 | // unknown reason. 39 | // Unfortunately, we need to bind to a port to easily use ruiedis. 40 | serverCmd := exec.Command( 41 | "redis-server", "--loglevel", "debug", 42 | "--bind", "127.0.0.1", "--port", "6380", 43 | ) 44 | serverCmd.Dir = t.TempDir() 45 | 46 | serverStdoutRd, serverStdoutWr := io.Pipe() 47 | t.Cleanup(func() { 48 | serverStdoutWr.Close() 49 | }) 50 | serverCmd.Stdout = io.MultiWriter( 51 | &testWriter{prefix: "server", t: t}, 52 | serverStdoutWr, 53 | ) 54 | serverCmd.Stderr = &testWriter{prefix: "server", t: t} 55 | 56 | err := serverCmd.Start() 57 | require.NoError(t, err) 58 | t.Cleanup(func() { 59 | serverCmd.Process.Kill() 60 | }) 61 | 62 | const addr = "127.0.0.1:6380" 63 | // Redis will print out the socket path when it's ready to server. 64 | sc := bufio.NewScanner(serverStdoutRd) 65 | for sc.Scan() { 66 | if !strings.Contains(sc.Text(), "Ready to accept connections") { 67 | continue 68 | } 69 | return addr 70 | } 71 | t.Fatalf("failed to start redis-server") 72 | panic("unreachable") 73 | } 74 | 75 | var ( 76 | payload1B = strings.Repeat("x", 1) 77 | payload1K = strings.Repeat("x", 1024) 78 | payload1M = strings.Repeat("x", 1024*1024) 79 | ) 80 | 81 | type benchClient interface { 82 | get(b *testing.B, ctx context.Context, payload string) 83 | } 84 | 85 | type redjetClient struct { 86 | redjet.Client 87 | } 88 | 89 | func (c *redjetClient) get(b *testing.B, ctx context.Context, payload string) { 90 | err := c.Command(ctx, "SET", "foo", payload).Ok() 91 | require.NoError(b, err) 92 | 93 | var r *redjet.Result 94 | 95 | for i := 0; i < b.N; i++ { 96 | r = c.Pipeline(ctx, r, "GET", "foo") 97 | } 98 | 99 | for r.Next() { 100 | read, err := r.WriteTo(io.Discard) 101 | require.NoError(b, err) 102 | if read != int64(len(payload)) { 103 | b.Fatalf("read %d bytes, expected %d", read, len(payload)) 104 | } 105 | } 106 | } 107 | 108 | type redigoClient struct { 109 | redigo.Conn 110 | } 111 | 112 | func (c *redigoClient) get(b *testing.B, ctx context.Context, payload string) { 113 | err := c.Send("SET", "foo", payload) 114 | require.NoError(b, err) 115 | 116 | for i := 0; i < b.N; i++ { 117 | c.Send("GET", "foo") 118 | } 119 | err = c.Flush() 120 | require.NoError(b, err) 121 | for i := 0; i < b.N; i++ { 122 | _, err = c.Receive() 123 | require.NoError(b, err) 124 | } 125 | } 126 | 127 | type goredisClient struct { 128 | *goredis.Client 129 | } 130 | 131 | func (c *goredisClient) get(b *testing.B, ctx context.Context, payload string) { 132 | err := c.Set(ctx, "foo", payload, 0).Err() 133 | require.NoError(b, err) 134 | 135 | pipe := c.Pipeline() 136 | 137 | var results []*goredis.StringCmd 138 | for i := 0; i < b.N; i++ { 139 | results = append(results, pipe.Get(ctx, "foo")) 140 | } 141 | 142 | cmds, err := pipe.Exec(ctx) 143 | require.NoError(b, err) 144 | 145 | require.Equal(b, b.N, len(cmds)) 146 | 147 | for _, r := range results { 148 | s := r.Val() 149 | if len(s) != len(payload) { 150 | b.Fatalf("read %d bytes, expected %d", len(s), len(payload)) 151 | } 152 | } 153 | } 154 | 155 | type rueidisClient struct { 156 | rueidis.Client 157 | } 158 | 159 | func (c *rueidisClient) get(b *testing.B, ctx context.Context, payload string) { 160 | var cmds rueidis.Commands 161 | cmds = append(cmds, c.B().Set().Key("foo").Value(payload).Build()) 162 | 163 | for i := 0; i < b.N; i++ { 164 | cmds = append(cmds, c.B().Get().Key("foo").Build()) 165 | } 166 | 167 | for _, resp := range c.DoMulti(ctx, cmds...) { 168 | require.NoError(b, resp.Error()) 169 | } 170 | } 171 | 172 | var libFlag = flag.String("lib", "", "lib to benchmark") 173 | 174 | func BenchmarkGet(b *testing.B) { 175 | addr := startRedisServer(b) 176 | 177 | flag.Parse() 178 | 179 | var client benchClient 180 | switch *libFlag { 181 | case "redjet": 182 | client = &redjetClient{ 183 | redjet.Client{ 184 | ConnectionPoolSize: 16, 185 | IdleTimeout: 10 * time.Second, 186 | Dial: func(_ context.Context) (net.Conn, error) { 187 | return net.Dial("tcp", addr) 188 | }, 189 | }, 190 | } 191 | case "redigo": 192 | conn, err := redigo.Dial("tcp", addr) 193 | require.NoError(b, err) 194 | client = &redigoClient{conn} 195 | case "go-redis": 196 | client = &goredisClient{goredis.NewClient(&goredis.Options{ 197 | Network: "tcp", 198 | Addr: addr, 199 | })} 200 | case "rueidis": 201 | c, err := rueidis.NewClient(rueidis.ClientOption{ 202 | InitAddress: []string{"127.0.0.1:6380"}, 203 | }) 204 | require.NoError(b, err) 205 | client = &rueidisClient{c} 206 | case "": 207 | b.Fatalf("lib flag is required") 208 | default: 209 | b.Fatalf("unknown lib: %q", *libFlag) 210 | } 211 | 212 | ctx := context.Background() 213 | 214 | for _, payload := range []string{payload1B, payload1K, payload1M} { 215 | b.Run(humanize.Bytes(uint64(len(payload))), func(b *testing.B) { 216 | b.ReportAllocs() 217 | b.SetBytes(int64(len(payload))) 218 | client.get(b, ctx, payload) 219 | }) 220 | } 221 | } 222 | -------------------------------------------------------------------------------- /bench/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/coder/redjet/bench 2 | 3 | go 1.20 4 | 5 | // The benchmarks are separated into its own module to avoid ballooning 6 | // the main package with other Redis library dependencies. 7 | 8 | require ( 9 | github.com/coder/redjet v0.0.0-20230703191230-a607112e096c 10 | github.com/stretchr/testify v1.8.4 11 | ) 12 | 13 | replace github.com/coder/redjet => ../ 14 | 15 | require ( 16 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 17 | github.com/davecgh/go-spew v1.1.1 // indirect 18 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 19 | github.com/dustin/go-humanize v1.0.1 // indirect 20 | github.com/gomodule/redigo v1.8.9 // indirect 21 | github.com/pmezard/go-difflib v1.0.0 // indirect 22 | github.com/redis/go-redis/v9 v9.0.5 // indirect 23 | github.com/redis/rueidis v1.0.10 // indirect 24 | gopkg.in/yaml.v3 v3.0.1 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /bench/go.sum: -------------------------------------------------------------------------------- 1 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 2 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 3 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 5 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 7 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 8 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 9 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 10 | github.com/gomodule/redigo v1.8.9 h1:Sl3u+2BI/kk+VEatbj0scLdrFhjPmbxOc1myhDP41ws= 11 | github.com/gomodule/redigo v1.8.9/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE= 12 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 13 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/redis/go-redis/v9 v9.0.5 h1:CuQcn5HIEeK7BgElubPP8CGtE0KakrnbBSTLjathl5o= 15 | github.com/redis/go-redis/v9 v9.0.5/go.mod h1:WqMKv5vnQbRuZstUwxQI195wHy+t4PuXDOjzMvcuQHk= 16 | github.com/redis/rueidis v1.0.10 h1:5QbYwjVoC8sGFP3LXZIqUt6eqbpNY/ZZq9nYgCTRkqk= 17 | github.com/redis/rueidis v1.0.10/go.mod h1:+1zDH4a8XhwIbCSlIhVGIu6Xib0ZMDoBM0qGhHXc1ew= 18 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 19 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 20 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 21 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 22 | go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= 23 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 24 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 25 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 26 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 27 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 28 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package redjet 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "crypto/tls" 7 | "encoding/json" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "net" 12 | "net/url" 13 | "runtime" 14 | "strconv" 15 | "strings" 16 | "sync" 17 | "sync/atomic" 18 | "time" 19 | ) 20 | 21 | type Client struct { 22 | // ConnectionPoolSize limits the size of the connection pool. If 0, connections 23 | // are not pooled. 24 | ConnectionPoolSize int 25 | 26 | // IdleTimeout is the amount of time after which an idle connection will 27 | // be closed. 28 | IdleTimeout time.Duration 29 | 30 | // Dial is the function used to create new connections. 31 | Dial func(ctx context.Context) (net.Conn, error) 32 | 33 | // Setup is called after a new connection is established, but before any 34 | // commands are sent. It is useful for selecting a database or authenticating. 35 | // 36 | // See SetupAuth for authenticating with a username and password. 37 | Setup func(ctx context.Context, client *Client, pipe *Pipeline) error 38 | 39 | poolMu sync.Mutex 40 | pool *connPool 41 | } 42 | 43 | // New returns a new client that connects to addr with default settings. 44 | func New(addr string) *Client { 45 | c := &Client{ 46 | ConnectionPoolSize: 8, 47 | IdleTimeout: 5 * time.Minute, 48 | Dial: func(ctx context.Context) (net.Conn, error) { 49 | var d net.Dialer 50 | return d.DialContext(ctx, "tcp", addr) 51 | }, 52 | } 53 | return c 54 | } 55 | 56 | func NewFromURL(rawURL string) (*Client, error) { 57 | u, err := url.Parse(rawURL) 58 | if err != nil { 59 | return nil, fmt.Errorf("parse url: %w", err) 60 | } 61 | 62 | client := New(u.Host) 63 | 64 | var ( 65 | addr string 66 | isUnixSocket bool 67 | ) 68 | if u.Host == "" && u.Path != "" { 69 | // Likely using a unix socket. 70 | addr = u.Path 71 | isUnixSocket = true 72 | } else { 73 | addr = u.Host 74 | } 75 | 76 | if !isUnixSocket && u.Port() == "" { 77 | addr = net.JoinHostPort(addr, "6379") 78 | } 79 | 80 | switch u.Scheme { 81 | case "redis": 82 | client.Dial = func(ctx context.Context) (net.Conn, error) { 83 | var d net.Dialer 84 | proto := "tcp" 85 | if isUnixSocket { 86 | proto = "unix" 87 | } 88 | return d.DialContext(ctx, proto, addr) 89 | } 90 | case "rediss": 91 | client.Dial = func(ctx context.Context) (net.Conn, error) { 92 | var d tls.Dialer 93 | return d.DialContext(ctx, "tcp", addr) 94 | } 95 | default: 96 | return nil, fmt.Errorf("unsupported scheme: %s", u.Scheme) 97 | } 98 | 99 | if u.User != nil { 100 | pass, _ := u.User.Password() 101 | client.Setup = SetupAuth(u.User.Username(), pass) 102 | } 103 | 104 | return client, nil 105 | } 106 | 107 | func (c *Client) initPool() { 108 | c.poolMu.Lock() 109 | defer c.poolMu.Unlock() 110 | 111 | if c.pool == nil { 112 | c.pool = newConnPool(c.ConnectionPoolSize, c.IdleTimeout) 113 | } 114 | } 115 | 116 | type PoolStats struct { 117 | FreeConns int 118 | Returns int64 119 | FullPoolCloses int64 120 | CleanCycles int64 121 | } 122 | 123 | // PoolStats returns statistics about the connection pool. 124 | func (c *Client) PoolStats() PoolStats { 125 | if c.pool == nil { 126 | return PoolStats{} 127 | } 128 | return PoolStats{ 129 | FreeConns: len(c.pool.free), 130 | Returns: atomic.LoadInt64(&c.pool.returns), 131 | FullPoolCloses: atomic.LoadInt64(&c.pool.fullPoolCloses), 132 | CleanCycles: atomic.LoadInt64(&c.pool.cleanCycles), 133 | } 134 | } 135 | 136 | // getConn gets a new conn, wrapped in a Pipeline. The conn is already authenticated. 137 | func (c *Client) getConn(ctx context.Context) (*Pipeline, error) { 138 | c.initPool() 139 | 140 | if conn, ok := c.pool.tryGet(); ok { 141 | return c.newResult(conn), nil 142 | } 143 | 144 | nc, err := c.Dial(ctx) 145 | if err != nil { 146 | return nil, fmt.Errorf("dial: %w", err) 147 | } 148 | 149 | conn := &conn{ 150 | Conn: nc, 151 | lastUsed: time.Now(), 152 | wr: bufio.NewWriterSize(nc, 32*1024), 153 | rd: bufio.NewReaderSize(nc, 32*1024), 154 | miscBuf: make([]byte, 32*1024), 155 | } 156 | 157 | r := c.newResult(conn) 158 | 159 | if c.Setup != nil { 160 | err = c.Setup(ctx, c, r) 161 | if err != nil { 162 | nc.Close() 163 | return nil, fmt.Errorf("setup: %w", err) 164 | } 165 | } 166 | 167 | return r, nil 168 | } 169 | 170 | // SetupAuth returns a Setup function that authenticates with the given username and password. 171 | // 172 | // AuthUsername is the username used for authentication. 173 | // 174 | // If set, AuthPassword must also be set. If not using Redis ACLs, just 175 | // set AuthPassword. 176 | // 177 | // See more: https://redis.io/commands/auth/ 178 | // AuthPassword is the password used for authentication. 179 | // Authentication must be set before any other commands are sent, and 180 | // must not change during the lifetime of the client. 181 | // 182 | // See more: https://redis.io/commands/auth/ 183 | func SetupAuth( 184 | username string, 185 | password string, 186 | ) func(ctx context.Context, client *Client, pipe *Pipeline) error { 187 | return func(ctx context.Context, client *Client, pipe *Pipeline) error { 188 | switch { 189 | case username != "" && password != "": 190 | pipe = client.Pipeline(ctx, pipe, "AUTH", username, password) 191 | case password != "": 192 | pipe = client.Pipeline(ctx, pipe, "AUTH", password) 193 | default: 194 | return fmt.Errorf("username is set but password is not") 195 | } 196 | return pipe.Ok() 197 | } 198 | } 199 | 200 | func (c *Client) putConn(conn *conn) { 201 | if conn == nil { 202 | panic("cannot put nil conn") 203 | } 204 | c.initPool() 205 | // Clear any deadline. 206 | conn.SetDeadline(time.Time{}) 207 | c.pool.put(conn) 208 | } 209 | 210 | var crlf = []byte("\r\n") 211 | 212 | func writeBulkString(w *bufio.Writer, s string) { 213 | w.WriteString("$") 214 | w.WriteString(strconv.Itoa(len(s))) 215 | w.Write(crlf) 216 | w.WriteString(s) 217 | w.Write(crlf) 218 | } 219 | 220 | func writeBulkBytes(w *bufio.Writer, b []byte) { 221 | w.WriteString("$") 222 | w.WriteString(strconv.Itoa(len(b))) 223 | w.Write(crlf) 224 | w.Write(b) 225 | w.Write(crlf) 226 | } 227 | 228 | func writeBulkReader(w *bufio.Writer, rd LenReader) { 229 | w.WriteString("$") 230 | w.WriteString(strconv.Itoa(rd.Len())) 231 | w.Write(crlf) 232 | io.CopyN(w, rd, int64(rd.Len())) 233 | w.Write(crlf) 234 | } 235 | 236 | // LenReader is an io.Reader that also knows its length. 237 | // A new one may be created with NewLenReader. 238 | type LenReader interface { 239 | Len() int 240 | io.Reader 241 | } 242 | 243 | type lenReader struct { 244 | io.Reader 245 | size int 246 | } 247 | 248 | func (r *lenReader) Len() int { 249 | return r.size 250 | } 251 | 252 | func NewLenReader(r io.Reader, size int) LenReader { 253 | return &lenReader{ 254 | Reader: r, 255 | size: size, 256 | } 257 | } 258 | 259 | func (c *Client) newResult(conn *conn) *Pipeline { 260 | return &Pipeline{ 261 | closeCh: make(chan struct{}), 262 | conn: conn, 263 | client: c, 264 | } 265 | } 266 | 267 | // Pipeline sends a command to the server and returns the promise of a result. 268 | // r may be nil, as in the case of the first command in a pipeline. Each successive 269 | // call to Pipeline should re-use the last returned Pipeline. 270 | // 271 | // Known arg types are strings, []byte, LenReader, and fmt.Stringer. All other types 272 | // will be converted to JSON. 273 | // 274 | // It is safe to keep a pipeline running for a long time, with many send and 275 | // receive cycles. 276 | // 277 | // Example: 278 | // 279 | // p := client.Pipeline(ctx, nil, "SET", "foo", "bar") 280 | // defer p.Close() 281 | // 282 | // p = client.Pipeline(ctx, r, "GET", "foo") 283 | // // Read the result of SET first. 284 | // err := p.Ok() 285 | // if err != nil { 286 | // // handle error 287 | // } 288 | // 289 | // got, err := p.Bytes() 290 | // if err != nil { 291 | // // handle error 292 | // } 293 | // fmt.Println(string(got)) 294 | func (c *Client) Pipeline(ctx context.Context, p *Pipeline, cmd string, args ...any) *Pipeline { 295 | var err error 296 | if p == nil { 297 | p, err = c.getConn(ctx) 298 | if err != nil { 299 | return &Pipeline{ 300 | protoErr: fmt.Errorf("get conn: %w", err), 301 | } 302 | } 303 | 304 | // We must take great care that Close is eventually called on the pipeline to 305 | // avoid leaking connections. 306 | runtime.SetFinalizer(p, func(p *Pipeline) { 307 | p.Close() 308 | }) 309 | go func() { 310 | select { 311 | case <-ctx.Done(): 312 | p.Close() 313 | case <-p.closeCh: 314 | } 315 | }() 316 | } 317 | 318 | cmd = strings.ToUpper(cmd) 319 | // Redis already gives a nice error if we send a non-subscribe command 320 | // while in subscribe mode. 321 | if isSubscribeCmd(cmd) { 322 | p.subscribeMode = true 323 | } 324 | 325 | // We're instructing redis that we're sending an array of the command 326 | // and its arguments. 327 | p.conn.wr.WriteByte('*') 328 | p.conn.wr.WriteString(strconv.Itoa(len(args) + 1)) 329 | p.conn.wr.Write(crlf) 330 | 331 | writeBulkString(p.conn.wr, cmd) 332 | 333 | for _, arg := range args { 334 | switch arg := arg.(type) { 335 | case string: 336 | writeBulkString(p.conn.wr, arg) 337 | case []byte: 338 | writeBulkBytes(p.conn.wr, arg) 339 | case LenReader: 340 | writeBulkReader(p.conn.wr, arg) 341 | case fmt.Stringer: 342 | writeBulkString(p.conn.wr, arg.String()) 343 | default: 344 | v, err := json.Marshal(arg) 345 | if err != nil { 346 | // It's relatively rare to get an error here. 347 | panic(fmt.Sprintf("failed to marshal %T: %v", arg, err)) 348 | } 349 | writeBulkBytes(p.conn.wr, v) 350 | } 351 | } 352 | 353 | p.pipeline.end++ 354 | return p 355 | } 356 | 357 | // Command sends a command to the server and returns the result. The error 358 | // is encoded into the result for ergonomics. 359 | // 360 | // See Pipeline for more information on argument types. 361 | // 362 | // The caller should call Close on the result when finished with it. 363 | func (c *Client) Command(ctx context.Context, cmd string, args ...any) *Pipeline { 364 | if isSubscribeCmd(cmd) { 365 | return &Pipeline{ 366 | // Close behavior becomes confusing when combining subscription 367 | // and CloseOnRead. 368 | protoErr: fmt.Errorf("cannot use Command with subscribe command %s, use Pipeline instead", cmd), 369 | } 370 | } 371 | r := c.Pipeline(ctx, nil, cmd, args...) 372 | r.CloseOnRead = true 373 | return r 374 | } 375 | 376 | func (c *Client) Close() error { 377 | c.poolMu.Lock() 378 | defer c.poolMu.Unlock() 379 | 380 | var merr error 381 | if c.pool != nil { 382 | close(c.pool.cancelClean) 383 | <-c.pool.cleanExited 384 | // The cleaner may read free until it exits. 385 | close(c.pool.free) 386 | for conn := range c.pool.free { 387 | err := conn.Close() 388 | merr = errors.Join(merr, err) 389 | } 390 | c.pool.cleanTicker.Stop() 391 | c.pool = nil 392 | } 393 | return merr 394 | } 395 | -------------------------------------------------------------------------------- /client_test.go: -------------------------------------------------------------------------------- 1 | package redjet_test 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "io" 7 | "net" 8 | "strconv" 9 | "strings" 10 | "sync" 11 | "sync/atomic" 12 | "testing" 13 | "time" 14 | 15 | "github.com/coder/redjet" 16 | "github.com/coder/redjet/redtest" 17 | "github.com/stretchr/testify/assert" 18 | "github.com/stretchr/testify/require" 19 | "go.uber.org/goleak" 20 | ) 21 | 22 | func pingPong(t *testing.T, c *redjet.Client) { 23 | ctx := context.Background() 24 | got, err := c.Command(ctx, "PING").String() 25 | require.NoError(t, err) 26 | require.Equal(t, "PONG", got) 27 | } 28 | 29 | func TestNewFromURL(t *testing.T) { 30 | t.Parallel() 31 | 32 | t.Run("Normal", func(t *testing.T) { 33 | t.Parallel() 34 | 35 | addr, _ := redtest.StartRedisServer(t) 36 | 37 | c, err := redjet.NewFromURL("redis://" + addr) 38 | require.NoError(t, err) 39 | defer c.Close() 40 | 41 | pingPong(t, c) 42 | }) 43 | 44 | t.Run("Password", func(t *testing.T) { 45 | t.Parallel() 46 | 47 | addr, _ := redtest.StartRedisServer(t, "--requirepass", "hunter2") 48 | 49 | c, err := redjet.NewFromURL("redis://:hunter2@" + addr) 50 | require.NoError(t, err) 51 | defer c.Close() 52 | 53 | pingPong(t, c) 54 | }) 55 | } 56 | 57 | func TestClient_SetGet(t *testing.T) { 58 | t.Parallel() 59 | 60 | _, client := redtest.StartRedisServer(t) 61 | 62 | ctx := context.Background() 63 | 64 | err := client.Command(ctx, "SET", "foo", "bar").Ok() 65 | require.NoError(t, err) 66 | 67 | got, err := client.Command(ctx, "GET", "foo").Bytes() 68 | require.NoError(t, err) 69 | require.Equal(t, []byte("bar"), got) 70 | 71 | err = client.Command(ctx, "SET", "foo", []byte("bytebar")).Ok() 72 | require.NoError(t, err) 73 | 74 | got, err = client.Command(ctx, "GET", "foo").Bytes() 75 | require.NoError(t, err) 76 | require.Equal(t, []byte("bytebar"), got) 77 | } 78 | 79 | func TestClient_NotFound(t *testing.T) { 80 | t.Parallel() 81 | 82 | _, client := redtest.StartRedisServer(t) 83 | 84 | ctx := context.Background() 85 | 86 | got, err := client.Command(ctx, "GET", "brah").String() 87 | require.ErrorIs(t, err, redjet.ErrNil) 88 | require.Equal(t, "", got) 89 | } 90 | 91 | func TestClient_List(t *testing.T) { 92 | t.Parallel() 93 | 94 | _, client := redtest.StartRedisServer(t) 95 | 96 | ctx := context.Background() 97 | 98 | arr, err := client.Command(ctx, "LRANGE", "foo", "-1", -1).Strings() 99 | require.NoError(t, err) 100 | require.Empty(t, arr) 101 | 102 | n, err := client.Command(ctx, "RPUSH", "foo", "bar").Int() 103 | require.NoError(t, err) 104 | require.Equal(t, 1, n) 105 | 106 | arr, err = client.Command(ctx, "LRANGE", "foo", "-1", -1).Strings() 107 | require.NoError(t, err) 108 | require.Equal(t, []string{"bar"}, arr) 109 | } 110 | 111 | func TestClient_Race(t *testing.T) { 112 | t.Parallel() 113 | 114 | _, client := redtest.StartRedisServer(t) 115 | 116 | ctx := context.Background() 117 | 118 | // Ensure connections are getted reused correctly. 119 | var wg sync.WaitGroup 120 | for i := 0; i < 100; i++ { 121 | wg.Add(1) 122 | i := i 123 | go func() { 124 | defer wg.Done() 125 | 126 | key := strconv.Itoa(i) 127 | res := client.Command(ctx, "SET", key, "bar") 128 | if i%4 == 0 { 129 | err := res.Close() 130 | assert.NoError(t, err) 131 | return 132 | } 133 | 134 | err := res.Ok() 135 | assert.NoError(t, err) 136 | 137 | got, err := client.Command(ctx, "GET", key).Bytes() 138 | assert.NoError(t, err) 139 | assert.Equal(t, []byte("bar"), got) 140 | }() 141 | } 142 | 143 | wg.Wait() 144 | } 145 | 146 | func TestClient_IdleDrain(t *testing.T) { 147 | t.Parallel() 148 | 149 | t.Run("Unexpected", func(t *testing.T) { 150 | t.Parallel() 151 | _, client := redtest.StartRedisServer(t) 152 | 153 | require.Equal(t, 0, client.PoolStats().FreeConns) 154 | 155 | // Close comes before reading result. 156 | err := client.Command(context.Background(), "SET", "foo", "bar").Close() 157 | require.NoError(t, err) 158 | 159 | // Connection not returned to pool. 160 | require.Equal(t, 0, client.PoolStats().FreeConns) 161 | }) 162 | 163 | t.Run("Regular", func(t *testing.T) { 164 | t.Parallel() 165 | _, client := redtest.StartRedisServer(t) 166 | 167 | require.Equal(t, 0, client.PoolStats().FreeConns) 168 | err := client.Command(context.Background(), "SET", "foo", "bar").Ok() 169 | require.NoError(t, err) 170 | 171 | require.Equal(t, 1, client.PoolStats().FreeConns) 172 | 173 | // After the idle timeout, the connection should be drained. 174 | require.Eventually(t, func() bool { 175 | return client.PoolStats().FreeConns == 0 176 | }, time.Second, 10*time.Millisecond) 177 | }) 178 | } 179 | 180 | func TestClient_ShortRead(t *testing.T) { 181 | t.Parallel() 182 | _, client := redtest.StartRedisServer(t) 183 | // Test that the connection can be successfully re-used 184 | // even when pipeline is short-read. 185 | 186 | for i := 0; i < 100; i++ { 187 | var r *redjet.Pipeline 188 | r = client.Pipeline(context.Background(), r, "SET", "foo", "bar") 189 | r = client.Pipeline(context.Background(), r, "GET", "foo") 190 | if 1%10 == 0 { 191 | err := r.Close() 192 | require.NoError(t, err) 193 | continue 194 | } 195 | err := r.Ok() 196 | require.NoError(t, err) 197 | 198 | got, err := r.Bytes() 199 | require.NoError(t, err) 200 | require.Equal(t, []byte("bar"), got) 201 | 202 | err = r.Close() 203 | require.NoError(t, err) 204 | } 205 | } 206 | 207 | func TestClient_LenReader(t *testing.T) { 208 | t.Parallel() 209 | 210 | _, client := redtest.StartRedisServer(t) 211 | 212 | ctx := context.Background() 213 | 214 | v := strings.Repeat("x", 64) 215 | buf := bytes.NewBuffer( 216 | []byte(v), 217 | ) 218 | 219 | var _ redjet.LenReader = buf 220 | 221 | err := client.Command(ctx, "SET", "foo", buf).Ok() 222 | require.NoError(t, err) 223 | 224 | got, err := client.Command(ctx, "GET", "foo").Bytes() 225 | require.NoError(t, err) 226 | require.Equal(t, []byte(v), got) 227 | 228 | bigString := strings.Repeat("x", 1024) 229 | 230 | err = client.Command(ctx, "SET", "foo", redjet.NewLenReader( 231 | strings.NewReader(bigString), 232 | 16, 233 | )).Ok() 234 | require.NoError(t, err) 235 | 236 | got, err = client.Command(ctx, "GET", "foo").Bytes() 237 | require.NoError(t, err) 238 | require.Equal(t, []byte(bigString)[:16], got) 239 | } 240 | 241 | func TestClient_BadCmd(t *testing.T) { 242 | t.Parallel() 243 | 244 | _, client := redtest.StartRedisServer(t) 245 | 246 | ctx := context.Background() 247 | err := client.Command(ctx, "whatwhat").Ok() 248 | require.True(t, redjet.IsUnknownCommand(err)) 249 | require.Error(t, err) 250 | } 251 | 252 | func TestClient_Integer(t *testing.T) { 253 | t.Parallel() 254 | 255 | _, client := redtest.StartRedisServer(t) 256 | 257 | ctx := context.Background() 258 | err := client.Command(ctx, "SET", "foo", "123").Ok() 259 | require.NoError(t, err) 260 | 261 | got, err := client.Command(ctx, "GET", "foo").Int() 262 | require.NoError(t, err) 263 | require.EqualValues(t, 123, got) 264 | 265 | gotLen, err := client.Command(ctx, "STRLEN", "foo").Int() 266 | require.NoError(t, err) 267 | require.Equal(t, 3, gotLen) 268 | } 269 | 270 | func TestClient_Stringer(t *testing.T) { 271 | t.Parallel() 272 | 273 | _, client := redtest.StartRedisServer(t) 274 | 275 | ctx := context.Background() 276 | err := client.Command(ctx, "SET", "foo", time.Hour).Ok() 277 | require.NoError(t, err) 278 | 279 | got, err := client.Command(ctx, "GET", "foo").String() 280 | require.NoError(t, err) 281 | require.EqualValues(t, "1h0m0s", got) 282 | } 283 | 284 | func TestClient_JSON(t *testing.T) { 285 | t.Parallel() 286 | 287 | t.Run("GetSet", func(t *testing.T) { 288 | t.Parallel() 289 | _, client := redtest.StartRedisServer(t) 290 | 291 | var v struct { 292 | Foo string 293 | Bar int 294 | } 295 | 296 | v.Foo = "bar" 297 | v.Bar = 123 298 | 299 | ctx := context.Background() 300 | err := client.Command(ctx, "SET", "foo", v).Ok() 301 | require.NoError(t, err) 302 | 303 | resp := make(map[string]interface{}) 304 | err = client.Command(ctx, "GET", "foo").JSON(&resp) 305 | require.NoError(t, err) 306 | require.Equal(t, "bar", resp["Foo"]) 307 | require.Equal(t, float64(123), resp["Bar"]) 308 | }) 309 | t.Run("NotFound", func(t *testing.T) { 310 | t.Parallel() 311 | _, client := redtest.StartRedisServer(t) 312 | 313 | var resp map[string]interface{} 314 | ctx := context.Background() 315 | err := client.Command(ctx, "GET", "foo").JSON(&resp) 316 | require.ErrorIs(t, err, redjet.ErrNil) 317 | }) 318 | } 319 | 320 | func TestClient_MGet(t *testing.T) { 321 | t.Parallel() 322 | 323 | _, client := redtest.StartRedisServer(t) 324 | 325 | ctx := context.Background() 326 | err := client.Command(ctx, "MSET", "a", "antelope", "b", "bat", "c", "cat").Ok() 327 | require.NoError(t, err) 328 | 329 | cmd := client.Command(ctx, "MGET", "a", "b", "c") 330 | 331 | n, err := cmd.ArrayLength() 332 | require.NoError(t, err) 333 | require.Equal(t, 3, n) 334 | 335 | err = cmd.Close() 336 | require.NoError(t, err) 337 | 338 | got, err := client.Command(ctx, "MGET", "a", "b", "c").Strings() 339 | require.NoError(t, err, "read %+v", got) 340 | require.Equal(t, []string{"antelope", "bat", "cat"}, got) 341 | } 342 | 343 | func TestClient_MGet_Nil(t *testing.T) { 344 | t.Parallel() 345 | 346 | _, client := redtest.StartRedisServer(t) 347 | 348 | ctx := context.Background() 349 | // Only set first and last keys. 350 | err := client.Command(ctx, "MSET", "a", "antelope", "c", "cat").Ok() 351 | require.NoError(t, err) 352 | 353 | // As a special case of handling nil, we return empty strings for 354 | // missing keys. 355 | got, err := client.Command(ctx, "MGET", "a", "b", "c").Strings() 356 | assert.NoError(t, err) 357 | assert.Equal(t, []string{"antelope", "", "cat"}, got) 358 | } 359 | 360 | func TestClient_Auth(t *testing.T) { 361 | t.Parallel() 362 | const password = "hunt12" 363 | 364 | t.Run("Fail", func(t *testing.T) { 365 | t.Parallel() 366 | 367 | _, client := redtest.StartRedisServer(t, "--requirepass", password) 368 | ctx := context.Background() 369 | 370 | err := client.Command(ctx, "SET", "foo", "bar").Ok() 371 | require.Error(t, err) 372 | require.True(t, redjet.IsAuthError(err)) 373 | }) 374 | 375 | t.Run("Succeed", func(t *testing.T) { 376 | t.Parallel() 377 | 378 | _, client := redtest.StartRedisServer(t, "--requirepass", password) 379 | ctx := context.Background() 380 | client.Setup = redjet.SetupAuth( 381 | "", 382 | password, 383 | ) 384 | 385 | // It's imperative to test both SET and GET because the response 386 | // of SET matches the response of AUTH. 387 | err := client.Command(ctx, "SET", "foo", "flamingo").Ok() 388 | require.NoError(t, err) 389 | 390 | got, err := client.Command(ctx, "GET", "foo").Bytes() 391 | require.NoError(t, err) 392 | require.Equal(t, []byte("flamingo"), got) 393 | }) 394 | } 395 | 396 | func TestClient_PubSub(t *testing.T) { 397 | t.Parallel() 398 | 399 | t.Run("NoCommand", func(t *testing.T) { 400 | _, client := redtest.StartRedisServer(t) 401 | 402 | ctx := context.Background() 403 | subCmd := client.Command(ctx, "SUBSCRIBE", "foo") 404 | defer subCmd.Close() 405 | 406 | _, err := subCmd.NextSubMessage() 407 | require.Error(t, err) 408 | }) 409 | 410 | t.Run("OK", func(t *testing.T) { 411 | _, client := redtest.StartRedisServer(t) 412 | 413 | ctx := context.Background() 414 | 415 | subCmd := client.Pipeline(ctx, nil, "SUBSCRIBE", "foo") 416 | defer subCmd.Close() 417 | 418 | msg, err := subCmd.NextSubMessage() 419 | require.NoError(t, err) 420 | 421 | require.Equal(t, &redjet.SubMessage{ 422 | Channel: "foo", 423 | Type: "subscribe", 424 | Payload: "1", 425 | }, msg) 426 | 427 | pubPipe := client.Pipeline(ctx, nil, "PUBLISH", "foo", "bar") 428 | defer pubPipe.Close() 429 | 430 | n, err := pubPipe.Int() 431 | require.NoError(t, err) 432 | require.Equal(t, 1, n) 433 | 434 | msg, err = subCmd.NextSubMessage() 435 | require.NoError(t, err) 436 | 437 | require.Equal(t, &redjet.SubMessage{ 438 | Channel: "foo", 439 | Type: "message", 440 | Payload: "bar", 441 | }, msg) 442 | }) 443 | } 444 | 445 | func TestClient_ConnReuse(t *testing.T) { 446 | if testing.Short() { 447 | t.Skip("skipping in short mode") 448 | } 449 | 450 | // This test is not parallel because it is sensitive to timing. 451 | 452 | socket, client := redtest.StartRedisServer(t) 453 | 454 | var connsMade int64 455 | client.Dial = func(_ context.Context) (net.Conn, error) { 456 | atomic.AddInt64(&connsMade, 1) 457 | return net.Dial("unix", socket) 458 | } 459 | 460 | // Test that connections aren't created unnecessarily. 461 | 462 | start := time.Now() 463 | for i := 0; i < 16; i++ { 464 | time.Sleep(client.IdleTimeout / 4) 465 | 466 | err := client.Command(context.Background(), "SET", "foo", "bar").Ok() 467 | require.NoError(t, err) 468 | 469 | t.Logf("i=%d, sinceStart=%v", i, time.Since(start)) 470 | stats := client.PoolStats() 471 | require.Equal(t, int64(1+i), stats.Returns) 472 | require.Equal(t, int64(0), stats.FullPoolCloses) 473 | require.Equal(t, int64(1), connsMade) 474 | require.Equal(t, 1, stats.FreeConns) 475 | } 476 | 477 | time.Sleep(client.IdleTimeout * 4) 478 | 479 | stats := client.PoolStats() 480 | require.Equal(t, int64(1), atomic.LoadInt64(&connsMade)) 481 | require.Equal(t, 0, stats.FreeConns) 482 | 483 | // The clean cycle should've ran more than once by now. 484 | require.Greater(t, 485 | stats.CleanCycles, int64(1), 486 | ) 487 | } 488 | 489 | func Benchmark_Get(b *testing.B) { 490 | _, client := redtest.StartRedisServer(b) 491 | 492 | ctx := context.Background() 493 | 494 | payloadSizes := []int{1, 1024, 1024 * 1024} 495 | payloads := make([]string, len(payloadSizes)) 496 | for i, payloadSize := range payloadSizes { 497 | payloads[i] = strings.Repeat("x", payloadSize) 498 | } 499 | 500 | b.ResetTimer() 501 | 502 | for i, payloadSize := range payloadSizes { 503 | b.Run("Size="+strconv.Itoa(payloadSize), func(b *testing.B) { 504 | payload := payloads[i] 505 | 506 | err := client.Command(ctx, "SET", "foo", payload).Ok() 507 | require.NoError(b, err) 508 | 509 | b.ResetTimer() 510 | b.ReportAllocs() 511 | b.SetBytes(int64(len(payload))) 512 | 513 | const ( 514 | batchSize = 100 515 | ) 516 | var r *redjet.Pipeline 517 | // We avoid assert/require in the hot path since it meaningfully 518 | // affects the benchmark. 519 | get := func() { 520 | for r.Next() { 521 | n, err := r.WriteTo(io.Discard) 522 | require.NoError(b, err) 523 | if n != int64(len(payload)) { 524 | b.Fatalf("expected %d bytes, got %d", len(payload), n) 525 | } 526 | } 527 | } 528 | for i := 0; i < b.N; i++ { 529 | r = client.Pipeline(ctx, r, "GET", "foo") 530 | if (i+1)%batchSize == 0 { 531 | get() 532 | } 533 | } 534 | get() 535 | err = r.Close() 536 | require.NoError(b, err) 537 | b.StopTimer() 538 | }) 539 | } 540 | } 541 | 542 | func TestMain(m *testing.M) { 543 | goleak.VerifyTestMain(m) 544 | } 545 | -------------------------------------------------------------------------------- /conn.go: -------------------------------------------------------------------------------- 1 | package redjet 2 | 3 | import ( 4 | "bufio" 5 | "net" 6 | "sync" 7 | "sync/atomic" 8 | "time" 9 | ) 10 | 11 | type conn struct { 12 | net.Conn 13 | wr *bufio.Writer 14 | rd *bufio.Reader 15 | // miscBuf is used when bufio abstractions get in the way. 16 | miscBuf []byte 17 | lastUsed time.Time 18 | } 19 | 20 | type connPool struct { 21 | free chan *conn 22 | 23 | cancelClean chan struct{} 24 | cleanExited chan struct{} 25 | cleanTicker *time.Ticker 26 | 27 | cleanMu sync.Mutex 28 | 29 | // These metrics are only used for testing. 30 | cleanCycles int64 31 | returns int64 32 | fullPoolCloses int64 33 | 34 | idleTimeout time.Duration 35 | } 36 | 37 | func newConnPool(size int, idleTimeout time.Duration) *connPool { 38 | p := &connPool{ 39 | free: make(chan *conn, size), 40 | // 2 is chosen arbitrarily. 41 | cleanTicker: time.NewTicker(idleTimeout * 2), 42 | 43 | cancelClean: make(chan struct{}), 44 | cleanExited: make(chan struct{}), 45 | idleTimeout: idleTimeout, 46 | } 47 | go p.cleanLoop() 48 | return p 49 | } 50 | 51 | func (p *connPool) clean() { 52 | p.cleanMu.Lock() 53 | defer p.cleanMu.Unlock() 54 | 55 | atomic.AddInt64(&p.cleanCycles, 1) 56 | 57 | var ( 58 | ln = len(p.free) 59 | closed int 60 | ) 61 | // While the cleanMu is held, no getConn or putConn operations can happen. 62 | // Thus, none of these operations can block. 63 | for i := 0; i < ln; i++ { 64 | c, ok := <-p.free 65 | if !ok { 66 | panic("pool closed improperly") 67 | } 68 | if time.Since(c.lastUsed) > p.idleTimeout { 69 | c.Close() 70 | closed++ 71 | continue 72 | } 73 | 74 | p.free <- c 75 | } 76 | 77 | if len(p.free) != ln-closed { 78 | panic("pool size changed during clean") 79 | } 80 | } 81 | 82 | func (p *connPool) cleanLoop() { 83 | defer close(p.cleanExited) 84 | // We use a centralized routine for cleaning instead of AfterFunc on each 85 | // connection because the latter creates more garbage, even though it scales 86 | // logarithmically as opposed to linearly. 87 | for { 88 | select { 89 | case <-p.cancelClean: 90 | return 91 | case <-p.cleanTicker.C: 92 | p.clean() 93 | } 94 | } 95 | } 96 | 97 | // tryGet tries to get a connection from the pool. If there are no free 98 | // connections, it returns false. 99 | func (p *connPool) tryGet() (*conn, bool) { 100 | p.cleanMu.Lock() 101 | defer p.cleanMu.Unlock() 102 | 103 | select { 104 | case c, ok := <-p.free: 105 | if !ok { 106 | return nil, false 107 | } 108 | return c, true 109 | default: 110 | return nil, false 111 | } 112 | } 113 | 114 | // put returns a connection to the pool. 115 | // If the pool is full, the connection is closed. 116 | func (p *connPool) put(c *conn) { 117 | p.cleanMu.Lock() 118 | defer p.cleanMu.Unlock() 119 | 120 | c.lastUsed = time.Now() 121 | 122 | atomic.AddInt64(&p.returns, 1) 123 | 124 | select { 125 | case p.free <- c: 126 | default: 127 | atomic.AddInt64(&p.fullPoolCloses, 1) 128 | // Pool is full, just close the connection. 129 | c.Close() 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/coder/redjet 2 | 3 | go 1.20 4 | 5 | require ( 6 | github.com/stretchr/testify v1.8.4 7 | go.uber.org/goleak v1.2.1 8 | ) 9 | 10 | require ( 11 | github.com/davecgh/go-spew v1.1.1 // indirect 12 | github.com/kr/text v0.2.0 // indirect 13 | github.com/pmezard/go-difflib v1.0.0 // indirect 14 | gopkg.in/yaml.v3 v3.0.1 // indirect 15 | ) 16 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 2 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 3 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 4 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 5 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 6 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 7 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 10 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 11 | go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= 12 | go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= 13 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 14 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 15 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 16 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 17 | -------------------------------------------------------------------------------- /pipeline.go: -------------------------------------------------------------------------------- 1 | package redjet 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "strconv" 11 | "strings" 12 | "sync" 13 | "sync/atomic" 14 | ) 15 | 16 | // Error represents an error returned by the Redis server, as opposed to a 17 | // a protocol or network error. 18 | type Error struct { 19 | raw string 20 | } 21 | 22 | func (e *Error) Error() string { 23 | return "server: " + e.raw 24 | } 25 | 26 | // IsUnknownCommand returns whether err is an "unknown command" error. 27 | func IsUnknownCommand(err error) bool { 28 | var e *Error 29 | return errors.As(err, &e) && strings.HasPrefix(e.raw, "ERR unknown command") 30 | } 31 | 32 | func IsAuthError(err error) bool { 33 | var e *Error 34 | return errors.As(err, &e) && strings.HasPrefix(e.raw, "NOAUTH") 35 | } 36 | 37 | // pipeline contains state of a Redis pipeline. 38 | type pipeline struct { 39 | at int 40 | end int 41 | } 42 | 43 | // Pipeline is the result of a command. 44 | // 45 | // Its methods are not safe for concurrent use. 46 | type Pipeline struct { 47 | // CloseOnRead determines whether the Pipeline is closed after the first read. 48 | // 49 | // It is set to True when the result is returned from Command, and 50 | // False when it is returned from Pipeline. 51 | // 52 | // It is ignored if in the middle of reading an array, or if the result 53 | // is of a subscribe command. 54 | CloseOnRead bool 55 | 56 | // subscribeMode is set to true when the result is returned from Subscribe. 57 | // In this case, the connection cannot be reused. 58 | subscribeMode bool 59 | 60 | mu sync.Mutex 61 | 62 | closeCh chan struct{} 63 | closed int64 64 | 65 | // protoErr is set to non-nil if there is an unrecoverable protocol error. 66 | protoErr error 67 | 68 | conn *conn 69 | client *Client 70 | 71 | pipeline pipeline 72 | 73 | // arrayStack tracks the depth of the array processing. E.g. if we're one 74 | // level deep with 3 elements remaining, arrayStack will be [3]. 75 | arrayStack []int 76 | } 77 | 78 | func (r *Pipeline) Error() string { 79 | r.mu.Lock() 80 | defer r.mu.Unlock() 81 | if r.protoErr == nil { 82 | return "" 83 | } 84 | return r.protoErr.Error() 85 | } 86 | 87 | // readUntilNewline reads until a newline, returning the bytes without the newline. 88 | // the returned bytes are built on top of buf. 89 | func readUntilNewline(rd *bufio.Reader, buf []byte) ([]byte, error) { 90 | buf = buf[:0] 91 | for { 92 | b, err := rd.ReadByte() 93 | if err != nil { 94 | return nil, err 95 | } 96 | buf = append(buf, b) 97 | if b == '\n' { 98 | break 99 | } 100 | } 101 | return buf[:len(buf)-2], nil 102 | } 103 | 104 | // grower represents memory-buffer types that can grow to a given size. 105 | type grower interface { 106 | Grow(int) 107 | } 108 | 109 | var ( 110 | _ grower = (*bytes.Buffer)(nil) 111 | _ grower = (*strings.Builder)(nil) 112 | ) 113 | 114 | // ErrNil is a nil value. For example, it is returned for missing keys in 115 | // GET and MGET. 116 | var ErrNil = errors.New("(nil)") 117 | 118 | func readBulkString(w io.Writer, rd *bufio.Reader, copyBuf []byte) (int, error) { 119 | newlineBuf, err := readUntilNewline(rd, copyBuf) 120 | if err != nil { 121 | return 0, err 122 | } 123 | 124 | stringSize, err := strconv.Atoi(string(newlineBuf)) 125 | if err != nil { 126 | return 0, err 127 | } 128 | 129 | // n == -1 signals a nil value. 130 | if stringSize <= 0 { 131 | return 0, ErrNil 132 | } 133 | 134 | if g, ok := w.(grower); ok { 135 | g.Grow(stringSize) 136 | } 137 | 138 | // io.CopyN will allocate a buffer of size N when N is small, so we 139 | // replace the behavior here. 140 | 141 | var nn int64 142 | if stringSize > len(copyBuf) { 143 | nn, err = io.CopyBuffer(w, &io.LimitedReader{ 144 | R: rd, 145 | N: int64(stringSize), 146 | }, copyBuf) 147 | if err != nil { 148 | return int(nn), err 149 | } 150 | } else { 151 | // Fast-path for small strings. 152 | // This avoids allocating a LimitReader. 153 | buf := copyBuf[:stringSize] 154 | n, err := io.ReadFull(rd, buf) 155 | if err != nil { 156 | return n, err 157 | } 158 | 159 | n, err = w.Write(buf) 160 | if err != nil { 161 | return n, err 162 | } 163 | nn = int64(n) 164 | } 165 | 166 | // Discard CRLF 167 | if _, err := rd.Discard(2); err != nil { 168 | return int(nn), err 169 | } 170 | return int(nn), nil 171 | } 172 | 173 | var errClosed = fmt.Errorf("result closed") 174 | 175 | func (r *Pipeline) checkClosed() error { 176 | if atomic.LoadInt64(&r.closed) != 0 { 177 | return errClosed 178 | } 179 | return nil 180 | } 181 | 182 | var _ io.WriterTo = (*Pipeline)(nil) 183 | 184 | // WriteTo writes the result to w. 185 | // 186 | // r.CloseOnRead sets whether the result is closed after the first read. 187 | // 188 | // The result is never automatically closed if in progress of reading an array. 189 | func (r *Pipeline) WriteTo(w io.Writer) (int64, error) { 190 | r.mu.Lock() 191 | defer r.mu.Unlock() 192 | 193 | defer func() { 194 | if r.CloseOnRead && len(r.arrayStack) == 0 && !r.subscribeMode { 195 | r.close() 196 | } 197 | }() 198 | 199 | n, _, err := r.writeTo(w) 200 | if err != nil { 201 | return n, err 202 | } 203 | return n, nil 204 | } 205 | 206 | // ArrayStack returns the position of the result within an array. 207 | // 208 | // The returned slice must not be modified. 209 | func (r *Pipeline) ArrayStack() []int { 210 | r.mu.Lock() 211 | defer r.mu.Unlock() 212 | 213 | return r.arrayStack 214 | } 215 | 216 | // Strings returns the array result as a slice of strings. 217 | func (r *Pipeline) Strings() ([]string, error) { 218 | // Strings intentionally doesn't hold the mutex or interact with other 219 | // internal fields to demonstrate how to use the public API. 220 | defer func() { 221 | if r.CloseOnRead { 222 | r.Close() 223 | } 224 | }() 225 | 226 | // Read the array length. 227 | ln, err := r.ArrayLength() 228 | if err != nil { 229 | return nil, fmt.Errorf("read array length: %w", err) 230 | } 231 | 232 | if ln <= 0 { 233 | return nil, nil 234 | } 235 | 236 | var ss []string 237 | for i := 0; i < ln; i++ { 238 | s, err := r.String() 239 | if err != nil && !errors.Is(err, ErrNil) { 240 | return ss, fmt.Errorf("read string %d: %w", i, err) 241 | } 242 | ss = append(ss, s) 243 | } 244 | return ss, nil 245 | } 246 | 247 | type replyType byte 248 | 249 | const ( 250 | replyTypeSimpleString replyType = '+' 251 | replyTypeError replyType = '-' 252 | replyTypeInteger replyType = ':' 253 | replyTypeBulkString replyType = '$' 254 | replyTypeArray replyType = '*' 255 | ) 256 | 257 | // writeTo writes the result to w. The second return value is whether or not 258 | // the value indicates an array. 259 | // 260 | // It is the master function of the Pipeline type, centralizing key logic. 261 | func (r *Pipeline) writeTo(w io.Writer) (int64, replyType, error) { 262 | if err := r.checkClosed(); err != nil { 263 | return 0, 0, err 264 | } 265 | 266 | if r.protoErr != nil { 267 | return 0, 0, r.protoErr 268 | } 269 | 270 | if r.pipeline.at == r.pipeline.end && len(r.arrayStack) == 0 && !r.subscribeMode { 271 | return 0, 0, fmt.Errorf("no more results") 272 | } 273 | 274 | r.protoErr = r.conn.wr.Flush() 275 | if r.protoErr != nil { 276 | r.protoErr = fmt.Errorf("flush: %w", r.protoErr) 277 | return 0, 0, r.protoErr 278 | } 279 | 280 | var typByte byte 281 | typByte, r.protoErr = r.conn.rd.ReadByte() 282 | if r.protoErr != nil { 283 | r.protoErr = fmt.Errorf("read type: %w", r.protoErr) 284 | return 0, 0, r.protoErr 285 | } 286 | typ := replyType(typByte) 287 | 288 | // incrRead is how we advance the read state machine. 289 | incrRead := func(isNewArray bool) { 290 | if len(r.arrayStack) == 0 { 291 | r.pipeline.at++ 292 | return 293 | } 294 | // If we're in an array, we're not making progress on the inter-command pipeline, 295 | // we're just reading the array elements. 296 | i := len(r.arrayStack) - 1 297 | r.arrayStack[i] = r.arrayStack[i] - 1 298 | 299 | // We don't do this cleanup on new arrays so that 300 | // we can support stacks like [0, 2] for the final 301 | // list in a series of lists. 302 | if r.arrayStack[i] == 0 && !isNewArray { 303 | r.arrayStack = r.arrayStack[:i] 304 | // This was just cleanup, we move the pipeline forward. 305 | r.pipeline.at++ 306 | } 307 | } 308 | 309 | var s []byte 310 | 311 | switch typ { 312 | case replyTypeSimpleString, replyTypeInteger, replyTypeArray: 313 | // Simple string or integer 314 | s, r.protoErr = readUntilNewline(r.conn.rd, r.conn.miscBuf) 315 | if r.protoErr != nil { 316 | return 0, typ, r.protoErr 317 | } 318 | 319 | isNewArray := typ == '*' 320 | 321 | var n int 322 | n, r.protoErr = w.Write(s) 323 | incrRead(isNewArray) 324 | var newArraySize int 325 | if isNewArray { 326 | var err error 327 | // New array 328 | newArraySize, err = strconv.Atoi(string(s)) 329 | if err != nil { 330 | return 0, typ, fmt.Errorf("invalid array length %q", s) 331 | } 332 | if newArraySize > 0 { 333 | r.arrayStack = append(r.arrayStack, newArraySize) 334 | } 335 | } 336 | return int64(n), typ, r.protoErr 337 | case replyTypeBulkString: 338 | // Bulk string 339 | var ( 340 | n int 341 | err error 342 | ) 343 | n, err = readBulkString(w, r.conn.rd, r.conn.miscBuf) 344 | incrRead(false) 345 | // A nil is highly recoverable. 346 | if !errors.Is(err, ErrNil) { 347 | r.protoErr = err 348 | } 349 | return int64(n), typ, err 350 | case replyTypeError: 351 | // Error 352 | s, r.protoErr = readUntilNewline(r.conn.rd, r.conn.miscBuf) 353 | if r.protoErr != nil { 354 | return 0, typ, r.protoErr 355 | } 356 | incrRead(false) 357 | return 0, typ, &Error{ 358 | raw: string(s), 359 | } 360 | default: 361 | r.protoErr = fmt.Errorf("unknown type %q", typ) 362 | return 0, typ, r.protoErr 363 | } 364 | } 365 | 366 | // Bytes returns the result as a byte slice. 367 | // 368 | // Refer to r.CloseOnRead for whether the result is closed after the first read. 369 | func (r *Pipeline) Bytes() ([]byte, error) { 370 | var buf bytes.Buffer 371 | _, err := r.WriteTo(&buf) 372 | return buf.Bytes(), err 373 | } 374 | 375 | // JSON unmarshals the result into v. 376 | func (r *Pipeline) JSON(v interface{}) error { 377 | b, err := r.Bytes() 378 | if err != nil { 379 | return err 380 | } 381 | return json.Unmarshal(b, v) 382 | } 383 | 384 | // String returns the result as a string. 385 | // 386 | // Refer to r.CloseOnRead for whether the result is closed after the first read. 387 | func (r *Pipeline) String() (string, error) { 388 | var sb strings.Builder 389 | _, err := r.WriteTo(&sb) 390 | return sb.String(), err 391 | } 392 | 393 | // Int returns the result as an integer. 394 | // 395 | // Refer to r.CloseOnRead for whether the result is closed after the first read. 396 | func (r *Pipeline) Int() (int, error) { 397 | s, err := r.String() 398 | if err != nil { 399 | return 0, err 400 | } 401 | return strconv.Atoi(s) 402 | } 403 | 404 | // ArrayLength reads the next message as an array length. 405 | // It does not close the Pipeline even if CloseOnRead is true. 406 | func (r *Pipeline) ArrayLength() (int, error) { 407 | r.mu.Lock() 408 | defer r.mu.Unlock() 409 | 410 | var buf bytes.Buffer 411 | _, typ, err := r.writeTo(&buf) 412 | if err != nil { 413 | return 0, err 414 | } 415 | 416 | if typ != replyTypeArray { 417 | return 0, fmt.Errorf("expected array, got %q", typ) 418 | } 419 | 420 | gotN, err := strconv.Atoi(buf.String()) 421 | if err != nil { 422 | return 0, fmt.Errorf("invalid array length %q", buf.String()) 423 | } 424 | 425 | // -1 is a nil array. 426 | if gotN <= 0 { 427 | return gotN, nil 428 | } 429 | 430 | if len(r.arrayStack) == 0 { 431 | return 0, fmt.Errorf("bug: array stack not set") 432 | } 433 | 434 | // Sanity check that we've populated the array stack correctly. 435 | if r.arrayStack[len(r.arrayStack)-1] != gotN { 436 | // This should be impossible. 437 | return 0, fmt.Errorf("array stack mismatch (expected %d, got %d)", r.arrayStack[len(r.arrayStack)-1], gotN) 438 | } 439 | 440 | return gotN, nil 441 | } 442 | 443 | // Ok returns whether the result is "OK". Note that it may fail even if the 444 | // 445 | // command succeeded. For example, a successful GET will return a value. 446 | func (r *Pipeline) Ok() error { 447 | got, err := r.Bytes() 448 | if err != nil { 449 | return err 450 | } 451 | if !bytes.Equal(got, []byte("OK")) { 452 | return fmt.Errorf("expected OK, got %q", got) 453 | } 454 | return nil 455 | } 456 | 457 | // Next returns true if there are more results to read. 458 | func (r *Pipeline) Next() bool { 459 | if r == nil { 460 | return false 461 | } 462 | 463 | r.mu.Lock() 464 | defer r.mu.Unlock() 465 | 466 | return r.HasMore() 467 | } 468 | 469 | // HasMore returns true if there are more results to read. 470 | func (r *Pipeline) HasMore() bool { 471 | if r.protoErr != nil { 472 | return false 473 | } 474 | 475 | var arrays int 476 | for _, n := range r.arrayStack { 477 | arrays += n 478 | } 479 | 480 | return r.pipeline.at < r.pipeline.end || arrays > 0 481 | } 482 | 483 | // Close releases all resources associated with the result. 484 | // 485 | // It is safe to call Close multiple times. 486 | func (r *Pipeline) Close() error { 487 | if r == nil { 488 | return nil 489 | } 490 | r.mu.Lock() 491 | defer r.mu.Unlock() 492 | 493 | return r.close() 494 | } 495 | 496 | func (r *Pipeline) close() error { 497 | if !atomic.CompareAndSwapInt64(&r.closed, 0, 1) { 498 | // double-close 499 | return nil 500 | } 501 | 502 | if r.closeCh != nil { 503 | close(r.closeCh) 504 | } 505 | 506 | conn := r.conn 507 | // r.conn is set to nil to prevent accidental reuse. 508 | r.conn = nil 509 | // Only return conn when it is in a known good state. 510 | if r.protoErr == nil && !r.subscribeMode && !r.HasMore() { 511 | r.client.putConn(conn) 512 | return nil 513 | } 514 | 515 | if conn != nil { 516 | return conn.Close() 517 | } 518 | 519 | return nil 520 | } 521 | -------------------------------------------------------------------------------- /pubsub.go: -------------------------------------------------------------------------------- 1 | package redjet 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | type SubMessageType string 8 | 9 | const ( 10 | SubMessageSubscribe SubMessageType = "subscribe" 11 | SubMessageMessage SubMessageType = "message" 12 | ) 13 | 14 | // SubMessage is a message received from a pubsub subscription. 15 | type SubMessage struct { 16 | // Type is either "subscribe" (acknowledgement of subscription) or "message" 17 | Type SubMessageType 18 | Channel string 19 | 20 | // Payload is the number of channels subscribed to if Type is "subscribe", 21 | Payload string 22 | } 23 | 24 | // NextSubMessage reads the next subscribe from the pipeline. 25 | // Read more: https://redis.io/docs/manual/pubsub/. 26 | // 27 | // It does not close the Pipeline even if CloseOnRead is true. 28 | func (r *Pipeline) NextSubMessage() (*SubMessage, error) { 29 | // NextSubMessage is implemented without using internal methods to 30 | // demonstrate how to use the public API. 31 | 32 | ln, err := r.ArrayLength() 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | if ln != 3 { 38 | return nil, fmt.Errorf("expected 3 elements, got %d", ln) 39 | } 40 | 41 | var msg SubMessage 42 | for i := 0; i < ln; i++ { 43 | s, err := r.String() 44 | if err != nil { 45 | return nil, err 46 | } 47 | switch i { 48 | case 0: 49 | msg.Type = SubMessageType(s) 50 | case 1: 51 | msg.Channel = s 52 | case 2: 53 | msg.Payload = s 54 | } 55 | } 56 | return &msg, nil 57 | } 58 | 59 | func isSubscribeCmd(cmd string) bool { 60 | switch cmd { 61 | case "SUBSCRIBE", "PSUBSCRIBE", "UNSUBSCRIBE", "PUNSUBSCRIBE", "QUIT", "RESET": 62 | return true 63 | default: 64 | return false 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /redcache/cache.go: -------------------------------------------------------------------------------- 1 | // Package redcache provides a simple cache implementation using Redis as a backend. 2 | package redcache 3 | 4 | import ( 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "time" 9 | 10 | "github.com/coder/redjet" 11 | ) 12 | 13 | // Cache is a simple cache implementation using Redis as a backend. 14 | type Cache[V any] struct { 15 | TTL time.Duration 16 | Client *redjet.Client 17 | // Prefix is the prefix used for all keys in the cache. 18 | Prefix string 19 | } 20 | 21 | // Do executes fn and caches the result for key. If the value is already cached, 22 | // it is returned immediately. 23 | // 24 | // Do uses JSON to marshal and unmarshal values. It may not perform well 25 | // for large values. 26 | func (c *Cache[V]) Do( 27 | ctx context.Context, 28 | key string, 29 | fn func() (V, error), 30 | ) (V, error) { 31 | fullKey := c.Prefix + key 32 | r := c.Client.Pipeline(ctx, nil, "GET", fullKey) 33 | defer r.Close() 34 | 35 | var v V 36 | 37 | got, err := r.Bytes() 38 | if err != nil { 39 | return v, err 40 | } 41 | 42 | if len(got) > 0 { 43 | err = json.Unmarshal(got, &v) 44 | if err != nil { 45 | return v, fmt.Errorf("unmarshal cached value: %w", err) 46 | } 47 | return v, nil 48 | } 49 | 50 | v, err = fn() 51 | if err != nil { 52 | return v, err 53 | } 54 | 55 | b, err := json.Marshal(v) 56 | if err != nil { 57 | return v, fmt.Errorf("marshal value: %w", err) 58 | } 59 | 60 | exp := c.TTL / time.Millisecond 61 | 62 | if exp == 0 { 63 | return v, fmt.Errorf("TTL is %v, but must be > %v", c.TTL, time.Millisecond) 64 | } 65 | 66 | r = c.Client.Pipeline(ctx, r, "SET", fullKey, b, "PX", int(exp)) 67 | err = r.Ok() 68 | if err != nil { 69 | return v, fmt.Errorf("cache set: %w", err) 70 | } 71 | 72 | return v, nil 73 | } 74 | -------------------------------------------------------------------------------- /redcache/cache_test.go: -------------------------------------------------------------------------------- 1 | package redcache 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | "time" 7 | 8 | "github.com/coder/redjet/redtest" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestCache(t *testing.T) { 13 | _, client := redtest.StartRedisServer(t) 14 | 15 | cache := &Cache[int]{ 16 | Client: client, 17 | TTL: 1 * time.Second, 18 | } 19 | 20 | counter := 0 21 | ctx := context.Background() 22 | 23 | fn := func() (int, error) { 24 | counter++ 25 | return counter, nil 26 | } 27 | 28 | for i := 0; i < 10; i++ { 29 | v, err := cache.Do(ctx, "foo", fn) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | require.Equal(t, 1, v) 34 | } 35 | 36 | time.Sleep(cache.TTL) 37 | 38 | v, err := cache.Do(ctx, "foo", fn) 39 | require.NoError(t, err) 40 | 41 | require.Equal(t, 2, v) 42 | } 43 | -------------------------------------------------------------------------------- /redtest/server.go: -------------------------------------------------------------------------------- 1 | package redtest 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "io" 8 | "net" 9 | "os/exec" 10 | "path/filepath" 11 | "strings" 12 | "sync/atomic" 13 | "testing" 14 | "time" 15 | 16 | "github.com/coder/redjet" 17 | "github.com/stretchr/testify/require" 18 | ) 19 | 20 | type testWriter struct { 21 | prefix string 22 | t testing.TB 23 | } 24 | 25 | func (w *testWriter) Write(p []byte) (int, error) { 26 | sc := bufio.NewScanner(bytes.NewReader(p)) 27 | for sc.Scan() { 28 | w.t.Logf("%s: %s", w.prefix, sc.Text()) 29 | } 30 | return len(p), nil 31 | } 32 | 33 | func StartRedisServer(t testing.TB, args ...string) (string, *redjet.Client) { 34 | // Use short-hand r.sock instead of redis.sock since redis has a 104 35 | // character limit on unix socket paths. 36 | socket := filepath.Join(t.TempDir(), "r.sock") 37 | serverCmd := exec.Command( 38 | "redis-server", "--unixsocket", socket, "--loglevel", "debug", 39 | "--port", "0", 40 | ) 41 | serverCmd.Args = append(serverCmd.Args, args...) 42 | serverCmd.Dir = t.TempDir() 43 | 44 | serverStdoutRd, serverStdoutWr := io.Pipe() 45 | t.Cleanup(func() { 46 | serverStdoutWr.Close() 47 | }) 48 | serverCmd.Stdout = io.MultiWriter( 49 | &testWriter{prefix: "server", t: t}, 50 | serverStdoutWr, 51 | ) 52 | serverCmd.Stderr = &testWriter{prefix: "server", t: t} 53 | 54 | err := serverCmd.Start() 55 | require.NoError(t, err) 56 | t.Cleanup(func() { 57 | serverCmd.Process.Kill() 58 | }) 59 | 60 | var serverStarted int64 61 | 62 | time.AfterFunc(5*time.Second, func() { 63 | if atomic.LoadInt64(&serverStarted) == 0 { 64 | t.Errorf("redis-server failed to start") 65 | serverStdoutWr.Close() 66 | } 67 | }) 68 | 69 | // Redis will print out the socket path when it's ready to server. 70 | sc := bufio.NewScanner(serverStdoutRd) 71 | for sc.Scan() { 72 | if !strings.Contains(sc.Text(), socket) && !strings.Contains(sc.Text(), "Ready to accept connections unix") { 73 | continue 74 | } 75 | atomic.StoreInt64(&serverStarted, 1) 76 | c := &redjet.Client{ 77 | ConnectionPoolSize: 10, 78 | Dial: func(_ context.Context) (net.Conn, error) { 79 | return net.Dial("unix", socket) 80 | }, 81 | IdleTimeout: 100 * time.Millisecond, 82 | } 83 | t.Cleanup(func() { 84 | err := c.Close() 85 | require.NoError(t, err) 86 | }) 87 | return socket, c 88 | } 89 | t.Fatalf("failed to start redis-server, didn't find socket %q in stdout", socket) 90 | panic("unreachable") 91 | } 92 | --------------------------------------------------------------------------------