├── README.md ├── go.mod ├── go.sum └── main.go /README.md: -------------------------------------------------------------------------------- 1 | # pgred 2 | 3 | Key value store using the redis protocol with Postgres as a backend. 4 | 5 | - Zero configuration 6 | - Requires docker on local machine 7 | - Basic commands. GET, SET, DEL, FLUSHDB, PING 8 | - Serialized transaction commands BEGIN, ROLLBACK, COMMIT 9 | - Ephemeral. Data go bye-bye on shutdown 10 | - Experimental. You've been warned 11 | 12 | ## Running 13 | 14 | ``` 15 | go run main.go 16 | ``` 17 | 18 | ## Using 19 | 20 | ``` 21 | redis-cli -p 6380 22 | ``` 23 | 24 | Good luck 25 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/tidwall/ptx/pgtest 2 | 3 | go 1.23.2 4 | 5 | require ( 6 | github.com/jackc/pgx/v5 v5.7.1 7 | github.com/tidwall/redcon v1.6.2 8 | ) 9 | 10 | require ( 11 | github.com/jackc/pgpassfile v1.0.0 // indirect 12 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 13 | github.com/tidwall/btree v1.1.0 // indirect 14 | github.com/tidwall/match v1.1.1 // indirect 15 | golang.org/x/crypto v0.27.0 // indirect 16 | golang.org/x/text v0.18.0 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 5 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 6 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= 7 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 8 | github.com/jackc/pgx/v5 v5.7.1 h1:x7SYsPBYDkHDksogeSmZZ5xzThcTgRz++I5E+ePFUcs= 9 | github.com/jackc/pgx/v5 v5.7.1/go.mod h1:e7O26IywZZ+naJtWWos6i6fvWK+29etgITqrqHLfoZA= 10 | github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= 11 | github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 15 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 16 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 17 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 18 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 19 | github.com/tidwall/btree v1.1.0 h1:5P+9WU8ui5uhmcg3SoPyTwoI0mVyZ1nps7YQzTZFkYM= 20 | github.com/tidwall/btree v1.1.0/go.mod h1:TzIRzen6yHbibdSfK6t8QimqbUnoxUSrZfeW7Uob0q4= 21 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 22 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 23 | github.com/tidwall/redcon v1.6.2 h1:5qfvrrybgtO85jnhSravmkZyC0D+7WstbfCs3MmPhow= 24 | github.com/tidwall/redcon v1.6.2/go.mod h1:p5Wbsgeyi2VSTBWOcA5vRXrOb9arFTcU2+ZzFjqV75Y= 25 | golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= 26 | golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= 27 | golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= 28 | golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 29 | golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= 30 | golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= 31 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 32 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 33 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 34 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 35 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bufio" 5 | "context" 6 | "crypto/rand" 7 | "encoding/hex" 8 | "flag" 9 | "fmt" 10 | "os" 11 | "os/exec" 12 | "strings" 13 | 14 | "github.com/jackc/pgx/v5" 15 | "github.com/tidwall/redcon" 16 | ) 17 | 18 | var port int 19 | var pgurl string 20 | 21 | func main() { 22 | flag.IntVar(&port, "port", 6380, "tcp port") 23 | flag.Parse() 24 | var pgport = 30000 + (port % 30000) 25 | var buf [32]byte 26 | rand.Read(buf[:]) 27 | var pgpass = string(hex.EncodeToString(buf[:])) 28 | pgurl = fmt.Sprintf("postgres://postgres:%s@127.0.0.1:%d", pgpass, pgport) 29 | 30 | var pgname = "pgred" 31 | 32 | exec.Command("docker", "stop", pgname).Run() 33 | exec.Command("docker", "rm", pgname).Run() 34 | defer func() { 35 | exec.Command("docker", "stop", pgname).Run() 36 | exec.Command("docker", "rm", pgname).Run() 37 | }() 38 | 39 | tag := "postgres:17-alpine" 40 | fmt.Printf("Starting up %s (port %d)...\n", tag, pgport) 41 | 42 | cmd := exec.Command("docker", "run", 43 | "--name", pgname, 44 | "--rm", 45 | "-e", "POSTGRES_PASSWORD="+pgpass, 46 | "-p", fmt.Sprintf("%d:5432", pgport), 47 | tag) 48 | cmd.Stdout = os.Stdout 49 | errrd, err := cmd.StderrPipe() 50 | if err != nil { 51 | fmt.Fprintln(os.Stderr, err) 52 | os.Exit(1) 53 | } 54 | go func() { 55 | listening := false 56 | rd := bufio.NewReader(errrd) 57 | for { 58 | bline, err := rd.ReadBytes('\n') 59 | if err != nil { 60 | break 61 | } 62 | line := string(bline) 63 | fmt.Printf("%s", line) 64 | if strings.Contains(line, "listening") && 65 | strings.Contains(line, "port 5432") { 66 | listening = true 67 | } 68 | if strings.Contains(line, "database system is ready to accept "+ 69 | "connections") && listening { 70 | go func() { 71 | initDatabase() 72 | startRedconServer() 73 | }() 74 | } 75 | } 76 | }() 77 | if err := cmd.Run(); err != nil { 78 | os.Exit(1) 79 | } 80 | } 81 | 82 | func initDatabase() { 83 | pconn, err := pgx.Connect(context.Background(), pgurl) 84 | if err != nil { 85 | fmt.Fprintf(os.Stderr, "%v\n", err) 86 | os.Exit(1) 87 | } 88 | defer pconn.Close(context.Background()) 89 | _, err = pconn.Exec(context.Background(), 90 | "create table fields (key text primary key, value text)") 91 | if err != nil { 92 | fmt.Fprintf(os.Stderr, "%v\n", err) 93 | os.Exit(1) 94 | } 95 | } 96 | 97 | func getkey(pconn *pgx.Conn, key string) (*string, error) { 98 | var value string 99 | err := pconn.QueryRow(context.Background(), 100 | "select value from fields where key = $1", key).Scan(&value) 101 | if err != nil { 102 | if err.Error() == "no rows in result set" { 103 | return nil, nil 104 | } 105 | return nil, err 106 | } 107 | return &value, nil 108 | } 109 | 110 | func setkey(pconn *pgx.Conn, key, value string) error { 111 | _, err := pconn.Exec(context.Background(), 112 | "insert into fields (key, value) values ($1, $2) "+ 113 | "on conflict (key) do "+ 114 | "update set value = $2", 115 | key, value) 116 | return err 117 | } 118 | 119 | func delkey(pconn *pgx.Conn, key string) (bool, error) { 120 | value, err := getkey(pconn, key) 121 | if err != nil || value == nil { 122 | return false, err 123 | } 124 | _, err = pconn.Exec(context.Background(), 125 | "delete from fields where key = $1", key) 126 | return err == nil, err 127 | } 128 | 129 | func startRedconServer() { 130 | fmt.Printf("Starting redcon server (port %d)\n", port) 131 | fmt.Fprintf(os.Stderr, "%s\n", 132 | redcon.ListenAndServe(fmt.Sprintf(":%d", port), 133 | func(conn redcon.Conn, cmd redcon.Command) { 134 | pconn := conn.Context().(*pgx.Conn) 135 | switch strings.ToLower(string(cmd.Args[0])) { 136 | case "ping": 137 | conn.WriteString("PONG") 138 | case "echo": 139 | if len(cmd.Args) < 2 { 140 | conn.WriteError("Wrong number of arguments") 141 | return 142 | } 143 | conn.WriteBulkString(string(cmd.Args[1])) 144 | case "flushdb", "flushall": 145 | _, err := pconn.Exec(context.Background(), 146 | "delete from fields") 147 | if err != nil { 148 | conn.WriteError(err.Error()) 149 | } else { 150 | conn.WriteString("OK") 151 | } 152 | case "begin": 153 | tags, err := pconn.Exec(context.Background(), 154 | "BEGIN ISOLATION LEVEL SERIALIZABLE") 155 | if err != nil { 156 | conn.WriteError(err.Error()) 157 | } else { 158 | conn.WriteString(fmt.Sprint(tags)) 159 | } 160 | case "del": 161 | if len(cmd.Args) < 2 { 162 | conn.WriteError("Wrong number of arguments") 163 | return 164 | } 165 | var count int 166 | for i := 1; i < len(cmd.Args); i++ { 167 | deleted, err := delkey(pconn, string(cmd.Args[i])) 168 | if err != nil { 169 | conn.WriteError(err.Error()) 170 | return 171 | } 172 | if deleted { 173 | count++ 174 | } 175 | } 176 | conn.WriteInt(count) 177 | case "commit", "rollback", "end", "abort": 178 | tags, err := pconn.Exec(context.Background(), 179 | string(cmd.Args[0])) 180 | if err != nil { 181 | conn.WriteError(err.Error()) 182 | } else { 183 | conn.WriteString(fmt.Sprint(tags)) 184 | } 185 | case "set": 186 | if len(cmd.Args) < 3 { 187 | conn.WriteError("Wrong number of arguments") 188 | return 189 | } 190 | err := setkey(pconn, string(cmd.Args[1]), 191 | string(cmd.Args[2])) 192 | if err != nil { 193 | conn.WriteError(err.Error()) 194 | } else { 195 | conn.WriteString("OK") 196 | } 197 | case "get": 198 | if len(cmd.Args) < 2 { 199 | conn.WriteError("Wrong number of arguments") 200 | return 201 | } 202 | value, err := getkey(pconn, string(cmd.Args[1])) 203 | if err != nil { 204 | conn.WriteError(err.Error()) 205 | } else if value == nil { 206 | conn.WriteNull() 207 | } else { 208 | conn.WriteBulkString(*value) 209 | } 210 | default: 211 | conn.WriteError("Unknown command") 212 | } 213 | }, 214 | func(conn redcon.Conn) bool { 215 | pconn, err := pgx.Connect(context.Background(), pgurl) 216 | if err != nil { 217 | fmt.Fprintf(os.Stderr, "%v\n", err) 218 | return false 219 | } 220 | conn.SetContext(pconn) 221 | fmt.Printf("Client connected %s\n", conn.RemoteAddr()) 222 | return true 223 | }, 224 | func(conn redcon.Conn, err error) { 225 | conn.Context().(*pgx.Conn).Close(context.Background()) 226 | }, 227 | ), 228 | ) 229 | os.Exit(1) 230 | } 231 | --------------------------------------------------------------------------------