github.com/creachadair/ffs@v0.17.3/blob/storetest/storetest.go (about) 1 // Copyright 2019 Michael J. Fromberger. All Rights Reserved. 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 // Package storetest provides correctness tests for implementations of the 16 // [blob.KV] interface. 17 package storetest 18 19 import ( 20 "context" 21 "errors" 22 "fmt" 23 "strconv" 24 "strings" 25 "sync" 26 "testing" 27 28 "github.com/creachadair/ffs/blob" 29 "github.com/creachadair/mds/mapset" 30 gocmp "github.com/google/go-cmp/cmp" 31 ) 32 33 type op = func(context.Context, *testing.T, blob.KV) 34 35 var script = []op{ 36 // Verify that the store is initially empty. 37 opList(""), 38 opLen(0), 39 40 // Get for a non-existing key should report an error. 41 opGet("nonesuch", "", blob.ErrKeyNotFound), 42 43 // Put a value in and verify that it is recorded. 44 opPut("fruit", "apple", false, nil), 45 opGet("fruit", "apple", nil), 46 47 // Put for an existing key fails when replace is false. 48 opPut("fruit", "pear", false, blob.ErrKeyExists), 49 50 // Put for an existing key works when replace is true. 51 opPut("fruit", "pear", true, nil), 52 opGet("fruit", "pear", nil), 53 54 opList("", "fruit"), 55 opLen(1), 56 57 // Add some additional keys. 58 opPut("nut", "hazelnut", false, nil), 59 opPut("animal", "cat", false, nil), 60 opPut("beverage", "piƱa colada", false, nil), 61 opPut("animal", "badger", true, nil), 62 63 opList("", "animal", "beverage", "fruit", "nut"), 64 opLen(4), 65 66 opPut("0", "ahoy there", false, nil), 67 opLen(5), 68 opGet("0", "ahoy there", nil), 69 opList("", "0", "animal", "beverage", "fruit", "nut"), 70 71 // Verify that listing respects the stop condition without error. 72 opListRange("", "cola", "0", "animal", "beverage"), 73 opListRange("animal", "last", "animal", "beverage", "fruit"), 74 opListRange("baker", "crude", "beverage"), 75 opListRange("cut", "done"), 76 77 // A missing empty key must report the correct error. 78 opGet("", "", blob.ErrKeyNotFound), 79 80 // Check list starting points. 81 opList("a", "animal", "beverage", "fruit", "nut"), 82 opList("animal", "animal", "beverage", "fruit", "nut"), 83 opList("animated", "beverage", "fruit", "nut"), 84 opList("goofy", "nut"), 85 opList("nutty"), 86 } 87 88 var delScript = []op{ 89 // Clean up. 90 opLen(5), 91 opDelete("0", nil), 92 opLen(4), 93 opDelete("animal", nil), 94 opLen(3), 95 opDelete("fruit", nil), 96 opLen(2), 97 opDelete("nut", nil), 98 opLen(1), 99 opDelete("beverage", nil), 100 opList(""), 101 opDelete("animal", blob.ErrKeyNotFound), 102 } 103 104 func opGet(key, want string, werr error) op { 105 return func(ctx context.Context, t *testing.T, s blob.KV) { 106 t.Helper() 107 got, err := s.Get(ctx, key) 108 if !errorOK(err, werr) { 109 t.Errorf("s.Get(%q): got error: %v, want: %v", key, err, werr) 110 } else if v := string(got); v != want { 111 t.Errorf("s.Get(%q): got %#q, want %#q", key, v, want) 112 } 113 } 114 } 115 116 func opPut(key, data string, replace bool, werr error) op { 117 return func(ctx context.Context, t *testing.T, s blob.KV) { 118 t.Helper() 119 err := s.Put(ctx, blob.PutOptions{ 120 Key: key, 121 Data: []byte(data), 122 Replace: replace, 123 }) 124 if !errorOK(err, werr) { 125 t.Errorf("s.Put(%q, %q, %v): got error: %v, want: %v", key, data, replace, err, werr) 126 } 127 } 128 } 129 130 func opDelete(key string, werr error) op { 131 return func(ctx context.Context, t *testing.T, s blob.KV) { 132 t.Helper() 133 err := s.Delete(ctx, key) 134 if !errorOK(err, werr) { 135 t.Errorf("s.Delete(%q): got error: %v, want: %v", key, err, werr) 136 } 137 } 138 } 139 140 func opList(from string, want ...string) op { 141 return opListRange(from, "", want...) 142 } 143 144 func opListRange(from, to string, want ...string) op { 145 return func(ctx context.Context, t *testing.T, s blob.KV) { 146 t.Helper() 147 var got []string 148 for key, err := range s.List(ctx, from) { 149 if err != nil { 150 t.Fatalf("s.List: unexpected error: %v", err) 151 } 152 if to != "" && key >= to { 153 break 154 } 155 got = append(got, key) 156 } 157 if diff := gocmp.Diff(got, want); diff != "" { 158 t.Errorf("s.List: wrong keys (-got, +want):\n%s", diff) 159 } 160 } 161 } 162 163 func opLen(want int64) op { 164 return func(ctx context.Context, t *testing.T, s blob.KV) { 165 t.Helper() 166 got, err := s.Len(ctx) 167 if err != nil { 168 t.Errorf("s.Len(): unexpected error: %v", err) 169 } 170 if got != want { 171 t.Errorf("s.Len(): got %d, want %d", got, want) 172 } 173 } 174 } 175 176 func errorOK(err, werr error) bool { 177 if werr == nil { 178 return err == nil 179 } 180 return errors.Is(err, werr) 181 } 182 183 // Run applies the test script to empty store s, then closes s. Any errors are 184 // reported to t. After Run returns, the contents of s are garbage. 185 func Run(t *testing.T, s blob.StoreCloser) { 186 k1, err := s.KV(t.Context(), "one") 187 if err != nil { 188 t.Fatalf("Create keyspace 1: %v", err) 189 } 190 k2, err := s.KV(t.Context(), "two") 191 if err != nil { 192 t.Fatalf("Create keyspace 2: %v", err) 193 } 194 195 // Run the test script on k1 and verify that k2 was not affected. 196 // Precondition: k1 and k2 are both initially empty. 197 runCheck := func(k1, k2 blob.KV) func(t *testing.T) { 198 return func(t *testing.T) { 199 for _, op := range script { 200 op(t.Context(), t, k1) 201 } 202 203 // Verify that the edits to k1 gave the expected result. 204 st, err := k1.Has(t.Context(), "fruit", "animal", "beverage", "nut", "nonesuch", "0") 205 if err != nil { 206 t.Errorf("KV 1 stat: unexpected error: %v", err) 207 } else if diff := gocmp.Diff(st, mapset.New("0", "animal", "fruit", "nut", "beverage")); diff != "" { 208 t.Errorf("KV 1 stat (-got, +want):\n%s", diff) 209 } 210 211 // Check that calling List inside List works. 212 var got []string 213 for key1, err := range k1.List(t.Context(), "fruit") { 214 if err != nil { 215 t.Errorf("List 1: unexpected error: %v", err) 216 break 217 } 218 got = append(got, strings.ToUpper(key1)) 219 for key2, err := range k1.List(t.Context(), "beverage") { 220 if err != nil { 221 t.Errorf("List 2: unexpected error: %v", err) 222 break 223 } 224 got = append(got, key2) 225 } 226 } 227 228 // Check that calling Has and Get inside List works. 229 for _, err := range k1.List(t.Context(), "") { 230 if err != nil { 231 t.Fatalf("List: unexpected error: %v", err) 232 } 233 if got, err := k1.Get(t.Context(), "nut"); err != nil || string(got) != "hazelnut" { 234 t.Errorf("Get nut: got (%q, %v), want (hazelnut, nil)", got, err) 235 } 236 237 if hs, err := k1.Has(t.Context(), "fruit"); err != nil || !hs.Has("fruit") { 238 t.Errorf("Has fruit: got (%v, %v), want (fruit, nil)", hs, err) 239 } 240 break 241 } 242 243 if diff := gocmp.Diff(got, []string{ 244 // Caps: outer list; Lowercase: inner list. 245 "FRUIT", "beverage", "fruit", "nut", "NUT", "beverage", "fruit", "nut", 246 }); diff != "" { 247 t.Errorf("List/List (-got, +want):\n%s", diff) 248 } 249 250 // Verify that the edits to k1 did not impart mass to k2. 251 if n, err := k2.Len(t.Context()); err != nil || n != 0 { 252 t.Errorf("KV 2 len: got (%v, %v), want (0, nil)", n, err) 253 } 254 } 255 } 256 257 // Run the deletion script on k and verify that k is empty afterward. 258 cleanup := func(k blob.KV) func(t *testing.T) { 259 return func(t *testing.T) { 260 for _, op := range delScript { 261 op(t.Context(), t, k) 262 } 263 264 // Verify that k is empty after cleanup. 265 if n, err := k.Len(t.Context()); err != nil || n != 0 { 266 t.Errorf("k1.Len: got (%v, %v), want (0, nil)", n, err) 267 } 268 } 269 } 270 271 casTest := func(s blob.Store) func(t *testing.T) { 272 return func(t *testing.T) { 273 cas, err := s.CAS(t.Context(), "testcas") 274 if err != nil { 275 t.Fatalf("Create CAS substore: %v", err) 276 } 277 const testData = "abcde" 278 key, err := cas.CASPut(t.Context(), []byte(testData)) 279 if err != nil { 280 t.Errorf("CASPut %q: unexpected error: %v", testData, err) 281 } else if err := cas.Delete(t.Context(), key); err != nil { 282 t.Errorf("Delete(%x): unexpected error: %v", key, err) 283 } 284 } 285 } 286 287 t.Run("Root", func(t *testing.T) { 288 t.Run("Basic", runCheck(k1, k2)) 289 t.Run("Cleanup", cleanup(k1)) 290 t.Run("CAS", casTest(s)) 291 }) 292 293 t.Run("Sub", func(t *testing.T) { 294 sub, err := s.Sub(t.Context(), "testsub") 295 if err != nil { 296 t.Fatalf("Create test substore: %v", err) 297 } 298 k3, err := sub.KV(t.Context(), "three") 299 if err != nil { 300 t.Fatalf("Create keyspace 3: %v", err) 301 } 302 t.Run("Basic", runCheck(k3, k1)) 303 t.Run("Cleanup", cleanup(k3)) 304 t.Run("CAS", casTest(sub)) 305 }) 306 307 // Exercise concurrency. 308 const numWorkers = 16 309 const numKeys = 16 310 311 taskKey := func(task, key int) string { 312 return fmt.Sprintf("task-%d-key-%d", task, key) 313 } 314 315 t.Run("Concurrent", func(t *testing.T) { 316 var wg sync.WaitGroup 317 for i := range numWorkers { 318 wg.Add(1) 319 i := i 320 go func() { 321 defer wg.Done() 322 323 for k := range numKeys { 324 key := taskKey(i, k+1) 325 value := strconv.Itoa(k) 326 if err := k2.Put(t.Context(), blob.PutOptions{ 327 Key: key, 328 Data: []byte(value), 329 Replace: true, 330 }); err != nil { 331 t.Errorf("Task %d: s.Put(%q=%q) failed: %v", i, key, value, err) 332 } 333 } 334 335 // List all the keys currently in the store, and pick out all those 336 // that belong to this task. 337 mine := fmt.Sprintf("task-%d-", i) 338 got := mapset.New[string]() 339 for key, err := range k2.List(t.Context(), "") { 340 if err != nil { 341 t.Errorf("Task %d: s.List failed: %v", i, err) 342 break 343 } 344 if strings.HasPrefix(key, mine) { 345 got.Add(key) 346 } 347 } 348 349 for k := range numKeys { 350 key := taskKey(i, k+1) 351 if val, err := k1.Get(t.Context(), key); err == nil { 352 t.Errorf("Task %d: k1.Get(%q) got %q, want error", i, key, val) 353 } 354 if _, err := k2.Get(t.Context(), key); err != nil { 355 t.Errorf("Task %d: k2.Get(%q) failed: %v", i, key, err) 356 } 357 358 // Verify that List did not miss any of this task's keys. 359 if !got.Has(key) { 360 t.Errorf("Task %d: k2.List missing key %q", i, key) 361 } 362 } 363 364 for k := range numKeys { 365 key := taskKey(i, k+1) 366 if err := k2.Delete(t.Context(), key); err != nil { 367 t.Errorf("Task %d: s.Delete(%q) failed: %v", i, key, err) 368 } 369 } 370 }() 371 } 372 wg.Wait() 373 374 // Verify that k2 is empty after the test settles. 375 if n, err := k2.Len(t.Context()); err != nil || n != 0 { 376 t.Errorf("k2.Len: got (%v, %v), want (0, nil)", n, err) 377 } 378 }) 379 380 if err := s.Close(t.Context()); err != nil { 381 t.Errorf("Close failed: %v", err) 382 } 383 } 384 385 type nopStoreCloser struct { 386 blob.Store 387 } 388 389 func (nopStoreCloser) Close(context.Context) error { return nil } 390 391 // NopCloser wraps a [blob.Store] with a no-op Close method to implement [blob.StoreCloser]. 392 func NopCloser(s blob.Store) blob.StoreCloser { return nopStoreCloser{Store: s} } 393 394 // SubKV traverses a sequence of zero or more subspace names beginning at s, 395 // and returns a KV for the last name in the sequence. Any error during 396 // traversal logs a failure in t. 397 func SubKV(t *testing.T, ctx context.Context, s blob.Store, names ...string) blob.KV { 398 return subWalk(t, ctx, s, names, func(s blob.Store, name string) (blob.KV, error) { 399 return s.KV(t.Context(), name) 400 }) 401 } 402 403 // SubCAS traverses a sequence of zero or more subspace names beginning at s, 404 // and returns a CAS for the last name in the sequence. Any error during 405 // traversal logs a failure in t. 406 func SubCAS(t *testing.T, ctx context.Context, s blob.Store, names ...string) blob.CAS { 407 return subWalk(t, ctx, s, names, func(s blob.Store, name string) (blob.CAS, error) { 408 return s.CAS(t.Context(), name) 409 }) 410 } 411 412 func subWalk[T any](t *testing.T, ctx context.Context, s blob.Store, names []string, f func(blob.Store, string) (T, error)) T { 413 t.Helper() 414 if len(names) == 0 { 415 t.Fatal("No keyspace name provided") 416 } 417 cur := s 418 for _, name := range names[:len(names)-1] { 419 next, err := cur.Sub(t.Context(), name) 420 if err != nil { 421 t.Fatalf("Sub(%q) failed: %v", name, err) 422 } 423 cur = next 424 } 425 last := names[len(names)-1] 426 v, err := f(cur, last) 427 if err != nil { 428 t.Fatalf("Lookup(%q) failed: %v", last, err) 429 } 430 return v 431 }