diff --git a/README.md b/README.md index 23c8cea..0460b2d 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,10 @@ err = cache.Set(ctx, "user:123", user) // sync write err = cache.SetAsync(ctx, "user:456", user) // async write ``` -GetSet deduplicates concurrent loads to prevent thundering herd situations: +Fetch deduplicates concurrent loads to prevent thundering herd situations: ```go -user, err := cache.GetSet("user:123", func() (User, error) { +user, err := cache.Fetch("user:123", func() (User, error) { return db.LoadUser("123") }) ``` @@ -70,9 +70,9 @@ fido has been exhaustively tested for performance using [gocachemark](https://gi Where fido wins: -- **Throughput**: 744M int gets/sec avg (2.7X faster than otter). 95M string sets/sec avg (26X faster than otter). -- **Hit rate**: Wins 6 of 9 workloads. Highest average across all datasets (+2.9% vs otter, +0.9% vs sieve). -- **Latency**: 8ns int gets, 10ns string gets, zero allocations (7X lower latency than otter) +- **Throughput**: 727M int gets/sec avg (2.7X faster than otter). 70M string sets/sec avg (22X faster than otter). +- **Hit rate**: Wins 6 of 9 workloads. Highest average across all datasets (+2.8% vs otter, +0.9% vs sieve). +- **Latency**: 8ns int gets, 10ns string gets, zero allocations (4X lower latency than otter) Where others win: diff --git a/benchmarks/gocachemark_results.json b/benchmarks/gocachemark_results.json index 6631c41..330b557 100644 --- a/benchmarks/gocachemark_results.json +++ b/benchmarks/gocachemark_results.json @@ -1,10 +1,10 @@ { - "timestamp": "2026-01-02T11:51:50-05:00", + "timestamp": "2026-01-02T18:18:43-05:00", "machineInfo": { "os": "darwin", "arch": "arm64", "goVersion": "go1.25.5", - "commandLine": "gocachemark -caches fido,otter,clock,theine,sieve,freelru-sync -outdir /var/folders/v8/x1bfz_f51ks821ly48ckh1640000gn/T/gocachemark-603065090", + "commandLine": "gocachemark -caches fido,otter,clock,theine,sieve,freelru-sync -outdir /var/folders/v8/x1bfz_f51ks821ly48ckh1640000gn/T/gocachemark-3006596758", "numCpu": 16 }, "hitRate": { @@ -29,25 +29,25 @@ }, { "rates": { - "131072": 57.174, - "16384": 54.691, - "262144": 59.061, - "32768": 55.119, - "65536": 55.963 + "131072": 57.241, + "16384": 54.725, + "262144": 59.042, + "32768": 55.174, + "65536": 55.888 }, "name": "otter", - "avgRate": 56.402 + "avgRate": 56.414 }, { "rates": { - "131072": 55.692, - "16384": 54.314, - "262144": 59.255, - "32768": 55.34, - "65536": 55.343 + "131072": 55.351, + "16384": 54.268, + "262144": 59.269, + "32768": 55.182, + "65536": 55.13 }, "name": "theine", - "avgRate": 55.989 + "avgRate": 55.84 }, { "rates": { @@ -97,25 +97,25 @@ }, { "rates": { - "131072": 73.821, - "16384": 34.702, - "262144": 80.572, - "32768": 43.8, - "65536": 63.643 + "131072": 73.858, + "16384": 34.819, + "262144": 80.577, + "32768": 45.66, + "65536": 63.89 }, "name": "otter", - "avgRate": 59.308 + "avgRate": 59.761 }, { "rates": { - "131072": 75.068, - "16384": 57.072, - "262144": 81.23, - "32768": 64.872, - "65536": 70.508 + "131072": 74.988, + "16384": 57.062, + "262144": 81.259, + "32768": 64.863, + "65536": 70.452 }, "name": "theine", - "avgRate": 69.75 + "avgRate": 69.725 }, { "rates": { @@ -166,24 +166,24 @@ { "rates": { "131072": 95.014, - "16384": 61.236, + "16384": 61.81, "262144": 95.014, - "32768": 73.285, - "65536": 86.683 + "32768": 73.303, + "65536": 86.671 }, "name": "otter", - "avgRate": 82.246 + "avgRate": 82.362 }, { "rates": { "131072": 95.014, - "16384": 63.206, + "16384": 63.109, "262144": 95.014, - "32768": 74.197, - "65536": 87.125 + "32768": 74.166, + "65536": 87.144 }, "name": "theine", - "avgRate": 82.911 + "avgRate": 82.889 }, { "rates": { @@ -233,25 +233,25 @@ }, { "rates": { - "131072": 85.752, - "16384": 79.687, + "131072": 85.754, + "16384": 79.609, "262144": 86.773, - "32768": 81.437, - "65536": 83.905 + "32768": 81.483, + "65536": 83.951 }, "name": "otter", - "avgRate": 83.511 + "avgRate": 83.514 }, { "rates": { - "131072": 84.171, - "16384": 80.235, + "131072": 84.132, + "16384": 80.26, "262144": 86.772, - "32768": 81.597, - "65536": 82.561 + "32768": 81.514, + "65536": 82.464 }, "name": "theine", - "avgRate": 83.067 + "avgRate": 83.029 }, { "rates": { @@ -301,25 +301,25 @@ }, { "rates": { - "131072": 35.27, - "16384": 19.931, - "262144": 42.646, - "32768": 23.783, - "65536": 28.9 + "131072": 35.866, + "16384": 19.37, + "262144": 43.009, + "32768": 23.76, + "65536": 28.936 }, "name": "otter", - "avgRate": 30.106 + "avgRate": 30.188 }, { "rates": { - "131072": 37.617, - "16384": 21.694, - "262144": 43.986, - "32768": 26.68, - "65536": 31.823 + "131072": 37.622, + "16384": 22.209, + "262144": 43.939, + "32768": 26.696, + "65536": 31.764 }, "name": "theine", - "avgRate": 32.36 + "avgRate": 32.446 }, { "rates": { @@ -369,25 +369,25 @@ }, { "rates": { - "131072": 24.648, - "16384": 23.393, - "262144": 25.017, - "32768": 23.827, - "65536": 23.824 + "131072": 24.719, + "16384": 23.629, + "262144": 25.07, + "32768": 23.549, + "65536": 24.388 }, "name": "otter", - "avgRate": 24.142 + "avgRate": 24.271 }, { "rates": { - "131072": 24.502, - "16384": 23.868, - "262144": 25.053, - "32768": 23.85, - "65536": 24.488 + "131072": 24.438, + "16384": 23.788, + "262144": 25.254, + "32768": 23.958, + "65536": 24.446 }, "name": "theine", - "avgRate": 24.352 + "avgRate": 24.377 }, { "rates": { @@ -437,25 +437,25 @@ }, { "rates": { - "131072": 92.936, - "16384": 90.424, - "262144": 94.685, - "32768": 91.607, - "65536": 92.508 + "131072": 92.949, + "16384": 90.617, + "262144": 94.586, + "32768": 91.672, + "65536": 92.518 }, "name": "otter", - "avgRate": 92.432 + "avgRate": 92.468 }, { "rates": { - "131072": 94.607, - "16384": 91.187, - "262144": 95.027, - "32768": 92.328, - "65536": 93.562 + "131072": 94.593, + "16384": 90.924, + "262144": 95.021, + "32768": 92.321, + "65536": 93.507 }, "name": "theine", - "avgRate": 93.342 + "avgRate": 93.273 }, { "rates": { @@ -506,24 +506,24 @@ { "rates": { "131072": 82.838, - "16384": 81.171, - "262144": 83.919, - "32768": 81.42, - "65536": 82.27 + "16384": 81.138, + "262144": 83.933, + "32768": 81.418, + "65536": 82.244 }, "name": "otter", - "avgRate": 82.324 + "avgRate": 82.314 }, { "rates": { - "131072": 83, - "16384": 81.789, - "262144": 84.216, - "32768": 81.752, - "65536": 83.128 + "131072": 82.996, + "16384": 81.785, + "262144": 84.214, + "32768": 81.591, + "65536": 83.085 }, "name": "theine", - "avgRate": 82.777 + "avgRate": 82.734 }, { "rates": { @@ -573,25 +573,25 @@ }, { "rates": { - "131072": 21.242, - "16384": 13.176, - "262144": 24.658, - "32768": 15.407, - "65536": 17.973 + "131072": 21.343, + "16384": 13.169, + "262144": 25.112, + "32768": 15.385, + "65536": 18.213 }, "name": "otter", - "avgRate": 18.491 + "avgRate": 18.645 }, { "rates": { - "131072": 22.412, - "16384": 14.1, - "262144": 26.036, - "32768": 16.712, - "65536": 19.569 + "131072": 22.481, + "16384": 14.109, + "262144": 26.033, + "32768": 16.587, + "65536": 19.549 }, "name": "theine", - "avgRate": 19.766 + "avgRate": 19.752 }, { "rates": { @@ -640,8 +640,8 @@ }, { "name": "theine", - "value": 60.479, - "diffFromFirstPct": 1.162 + "value": 60.452, + "diffFromFirstPct": 1.189 }, { "name": "clock", @@ -655,8 +655,8 @@ }, { "name": "otter", - "value": 58.773, - "diffFromFirstPct": 2.868 + "value": 58.882, + "diffFromFirstPct": 2.759 } ] }, @@ -668,7 +668,7 @@ "getAllocs": 0, "setNsOp": 16, "setAllocs": 0, - "setEvictNsOp": 117, + "setEvictNsOp": 118, "setEvictAllocs": 1, "avgNsOp": 13 }, @@ -676,31 +676,31 @@ "name": "otter", "getNsOp": 36, "getAllocs": 0, - "setNsOp": 152, + "setNsOp": 134, "setAllocs": 1, "setEvictNsOp": 161, "setEvictAllocs": 1, - "avgNsOp": 94 + "avgNsOp": 85 }, { "name": "theine", - "getNsOp": 84, + "getNsOp": 85, "getAllocs": 1, - "setNsOp": 121, + "setNsOp": 403, "setAllocs": 0, - "setEvictNsOp": 203, - "setEvictAllocs": 0, - "avgNsOp": 102.5 + "setEvictNsOp": 490, + "setEvictAllocs": 2, + "avgNsOp": 244 }, { "name": "sieve", "getNsOp": 23, "getAllocs": 0, - "setNsOp": 46, + "setNsOp": 45, "setAllocs": 0, "setEvictNsOp": 191, "setEvictAllocs": 3, - "avgNsOp": 34.5 + "avgNsOp": 34 }, { "name": "freelru-sync", @@ -714,13 +714,13 @@ }, { "name": "clock", - "getNsOp": 15, + "getNsOp": 16, "getAllocs": 0, - "setNsOp": 21, + "setNsOp": 22, "setAllocs": 0, - "setEvictNsOp": 97, + "setEvictNsOp": 98, "setEvictAllocs": 2, - "avgNsOp": 18 + "avgNsOp": 19 } ], "intKeys": [ @@ -730,53 +730,63 @@ "getAllocs": 0, "setNsOp": 14, "setAllocs": 0, - "setEvictNsOp": 100, + "setEvictNsOp": 99, "setEvictAllocs": 1, "avgNsOp": 11 }, { "name": "otter", - "getNsOp": 33, + "getNsOp": 32, "getAllocs": 0, "setNsOp": 133, "setAllocs": 1, "setEvictNsOp": 154, "setEvictAllocs": 1, - "avgNsOp": 83 + "avgNsOp": 82.5 }, { "name": "theine", - "getNsOp": 79, + "getNsOp": 80, "getAllocs": 1, - "setNsOp": 109, + "setNsOp": 348, "setAllocs": 0, - "setEvictNsOp": 182, - "setEvictAllocs": 0, - "avgNsOp": 94 + "setEvictNsOp": 445, + "setEvictAllocs": 2, + "avgNsOp": 214 } ], "getOrSet": [ { "name": "fido", - "nsOp": 10, + "nsOp": 11, "allocs": 0 }, { "name": "otter", - "nsOp": 55, + "nsOp": 54, "allocs": 1 + }, + { + "name": "theine", + "nsOp": 164, + "allocs": 4 } ], "summary": [ { "name": "fido", - "value": 11.333, + "value": 11.667, "diffFromFirstPct": 0 }, { "name": "otter", - "value": 77.333, - "diffFromFirstPct": 582.353 + "value": 73.833, + "diffFromFirstPct": 532.857 + }, + { + "name": "theine", + "value": 207.333, + "diffFromFirstPct": 1677.143 } ] }, @@ -790,223 +800,238 @@ "stringGet": [ { "qps": { - "1": 42611111.111, - "16": 554212222.222, - "32": 571598888.889, - "8": 338507777.778 + "1": 40337777.778, + "16": 551497777.778, + "32": 553137777.778, + "8": 336118888.889 }, "name": "fido", - "avgQps": 376732500 + "avgQps": 370273055.556 }, { "qps": { - "1": 18817777.778, - "16": 303140000, - "32": 302916666.667, - "8": 138186666.667 + "1": 18795555.556, + "16": 273820000, + "32": 287045555.556, + "8": 148082222.222 }, "name": "otter", - "avgQps": 190765277.778 + "avgQps": 181935833.333 }, { "qps": { - "1": 8894444.444, - "16": 212170000, - "32": 216884444.444, - "8": 125256666.667 + "1": 8343333.333, + "16": 199648888.889, + "32": 192542222.222, + "8": 118875555.556 }, "name": "theine", - "avgQps": 140801388.889 + "avgQps": 129852500 }, { "qps": { - "1": 34454444.444, - "16": 6132222.222, - "32": 6820000, - "8": 7112222.222 + "1": 29875555.556, + "16": 5764444.444, + "32": 6681111.111, + "8": 5844444.444 }, "name": "sieve", - "avgQps": 13629722.222 + "avgQps": 12041388.889 }, { "qps": { - "1": 23306666.667, - "16": 6271111.111, - "32": 7248888.889, - "8": 6792222.222 + "1": 23556666.667, + "16": 5998888.889, + "32": 7357777.778, + "8": 6650000 }, "name": "freelru-sync", - "avgQps": 10904722.222 + "avgQps": 10890833.333 }, { "qps": { - "1": 35351111.111, - "16": 5447777.778, - "32": 6131111.111, - "8": 6315555.556 + "1": 33841111.111, + "16": 5273333.333, + "32": 5978888.889, + "8": 6034444.444 }, "name": "clock", - "avgQps": 13311388.889 + "avgQps": 12781944.444 } ], "stringSet": [ { "qps": { - "1": 24293333.333, - "16": 133138888.889, - "32": 134443333.333, - "8": 87506666.667 + "1": 23137777.778, + "16": 108918888.889, + "32": 62390000, + "8": 86165555.556 }, "name": "fido", - "avgQps": 94845555.556 + "avgQps": 70153055.556 }, { "qps": { - "1": 6504444.444, - "16": 2684444.444, - "32": 2724444.444, - "8": 2853333.333 + "1": 4261111.111, + "16": 2653333.333, + "32": 2710000, + "8": 2900000 }, "name": "otter", - "avgQps": 3691666.667 + "avgQps": 3131111.111 }, { "qps": { - "1": 4934444.444, - "16": 3607777.778, - "32": 3263333.333, - "8": 4374444.444 + "1": 1817777.778, + "16": 2437777.778, + "32": 2237777.778, + "8": 2922222.222 }, "name": "theine", - "avgQps": 4045000 + "avgQps": 2353888.889 }, { "qps": { - "1": 14746666.667, - "16": 4804444.444, - "32": 4960000, - "8": 5114444.444 + "1": 13460000, + "16": 4642222.222, + "32": 4921111.111, + "8": 4904444.444 }, "name": "sieve", - "avgQps": 7406388.889 + "avgQps": 6981944.444 }, { "qps": { - "1": 21381111.111, - "16": 6144444.444, - "32": 6764444.444, - "8": 6497777.778 + "1": 21258888.889, + "16": 5294444.444, + "32": 6382222.222, + "8": 6235555.556 }, "name": "freelru-sync", - "avgQps": 10196944.444 + "avgQps": 9792777.778 }, { "qps": { - "1": 24325555.556, - "16": 5213333.333, - "32": 5980000, - "8": 5901111.111 + "1": 23530000, + "16": 5243333.333, + "32": 5884444.444, + "8": 5907777.778 }, "name": "clock", - "avgQps": 10355000 + "avgQps": 10141388.889 } ], "intGet": [ { "qps": { - "1": 86330000, - "16": 1106452222.222, - "32": 1101177777.778, - "8": 680472222.222 + "1": 69997777.778, + "16": 1098304444.444, + "32": 1071365555.556, + "8": 667631111.111 }, "name": "fido", - "avgQps": 743608055.556 + "avgQps": 726824722.222 }, { "qps": { - "1": 27390000, - "16": 438934444.444, - "32": 443368888.889, - "8": 179270000 + "1": 27565555.556, + "16": 429227777.778, + "32": 419898888.889, + "8": 213554444.444 }, "name": "otter", - "avgQps": 272240833.333 + "avgQps": 272561666.667 }, { "qps": { - "1": 10756666.667, - "16": 277337777.778, - "32": 287808888.889, - "8": 111711111.111 + "1": 10558888.889, + "16": 261548888.889, + "32": 262248888.889, + "8": 159151111.111 }, "name": "theine", - "avgQps": 171903611.111 + "avgQps": 173376944.444 } ], "intSet": [ { "qps": { - "1": 51112222.222, - "16": 207518888.889, - "32": 164695555.556, - "8": 137067777.778 + "1": 50716666.667, + "16": 142021111.111, + "32": 145757777.778, + "8": 145425555.556 }, "name": "fido", - "avgQps": 140098611.111 + "avgQps": 120980277.778 }, { "qps": { - "1": 6761111.111, - "16": 2684444.444, + "1": 7488888.889, + "16": 2724444.444, "32": 2736666.667, - "8": 2927777.778 + "8": 2921111.111 }, "name": "otter", - "avgQps": 3777500 + "avgQps": 3967777.778 }, { "qps": { - "1": 7514444.444, - "16": 3816666.667, - "32": 3555555.556, - "8": 4897777.778 + "1": 2697777.778, + "16": 2825555.556, + "32": 2435555.556, + "8": 3427777.778 }, "name": "theine", - "avgQps": 4946111.111 + "avgQps": 2846666.667 } ], "getOrSet": [ { "qps": { - "1": 39657777.778, - "16": 473301111.111, - "32": 495425555.556, - "8": 264533333.333 + "1": 35646666.667, + "16": 430981111.111, + "32": 464851111.111, + "8": 229120000 }, "name": "fido", - "avgQps": 318229444.444 + "avgQps": 290149722.222 }, { "qps": { - "1": 12233333.333, - "16": 172571111.111, - "32": 168641111.111, - "8": 96605555.556 + "1": 11203333.333, + "16": 163980000, + "32": 157985555.556, + "8": 94770000 }, "name": "otter", - "avgQps": 112512777.778 + "avgQps": 106984722.222 + }, + { + "qps": { + "1": 2752222.222, + "16": 30785555.556, + "32": 39228888.889, + "8": 17084444.444 + }, + "name": "theine", + "avgQps": 22462777.778 } ], "summary": [ { "name": "fido", - "value": 334702833.333, + "value": 315676166.667, "diffFromFirstPct": 0 }, { "name": "otter", - "value": 116597611.111, - "diffFromFirstPct": 65.164 + "value": 113716222.222, + "diffFromFirstPct": 63.977 + }, + { + "name": "theine", + "value": 66178555.556, + "diffFromFirstPct": 79.036 } ] }, @@ -1015,44 +1040,44 @@ { "name": "freelru-sync", "items": 32768, - "bytes": 36896008, + "bytes": 37044504, "bytesPerItem": -20, - "baselineBytes": 37574456 + "baselineBytes": 37712392 }, { "name": "otter", "items": 32768, - "bytes": 38071720, + "bytes": 38207816, "bytesPerItem": 15, - "baselineBytes": 37574456 + "baselineBytes": 37712392 }, { "name": "clock", "items": 32768, - "bytes": 38826712, - "bytesPerItem": 38, - "baselineBytes": 37574456 + "bytes": 38937896, + "bytesPerItem": 37, + "baselineBytes": 37712392 }, { "name": "fido", "items": 32768, - "bytes": 39201800, + "bytes": 39336464, "bytesPerItem": 49, - "baselineBytes": 37574456 + "baselineBytes": 37712392 }, { "name": "theine", "items": 32768, - "bytes": 40323512, - "bytesPerItem": 83, - "baselineBytes": 37574456 + "bytes": 40480712, + "bytesPerItem": 84, + "baselineBytes": 37712392 }, { "name": "sieve", "items": 32768, - "bytes": 41451288, + "bytes": 41585080, "bytesPerItem": 118, - "baselineBytes": 37574456 + "baselineBytes": 37712392 } ], "capacity": 32768, @@ -1093,10 +1118,10 @@ "theine" ], "silver": [ - "fido" + "otter" ], "bronze": [ - "sieve" + "fido" ] }, { @@ -1175,19 +1200,19 @@ "rankings": [ { "name": "fido", - "score": 72, + "score": 70, "rank": 1, "gold": 6, - "silver": 1, - "bronze": 1 + "silver": 0, + "bronze": 2 }, { "name": "sieve", - "score": 58, + "score": 53, "rank": 2, "gold": 1, "silver": 4, - "bronze": 4 + "bronze": 3 }, { "name": "theine", @@ -1212,6 +1237,14 @@ "gold": 0, "silver": 1, "bronze": 2 + }, + { + "name": "otter", + "score": 7, + "rank": 6, + "gold": 0, + "silver": 1, + "bronze": 0 } ] }, @@ -1249,6 +1282,9 @@ ], "silver": [ "otter" + ], + "bronze": [ + "theine" ] } ], @@ -1278,15 +1314,15 @@ "bronze": 0 }, { - "name": "freelru-sync", - "score": 5, + "name": "theine", + "score": 10, "rank": 4, "gold": 0, "silver": 0, - "bronze": 1 + "bronze": 2 }, { - "name": "theine", + "name": "freelru-sync", "score": 5, "rank": 5, "gold": 0, @@ -1340,10 +1376,10 @@ "fido" ], "silver": [ - "theine" + "otter" ], "bronze": [ - "otter" + "theine" ] }, { @@ -1353,6 +1389,9 @@ ], "silver": [ "otter" + ], + "bronze": [ + "theine" ] } ], @@ -1367,27 +1406,27 @@ }, { "name": "otter", - "score": 26, + "score": 28, "rank": 2, "gold": 0, - "silver": 3, - "bronze": 1 + "silver": 4, + "bronze": 0 }, { - "name": "theine", - "score": 17, + "name": "clock", + "score": 7, "rank": 3, "gold": 0, "silver": 1, - "bronze": 2 + "bronze": 0 }, { - "name": "clock", - "score": 7, + "name": "theine", + "score": 20, "rank": 4, "gold": 0, - "silver": 1, - "bronze": 0 + "silver": 0, + "bronze": 4 }, { "name": "freelru-sync", @@ -1448,39 +1487,39 @@ { "name": "fido", "rank": 1, - "score": 160, + "score": 158, "gold": 14, - "silver": 1, - "bronze": 1 + "silver": 0, + "bronze": 2 }, { "name": "otter", "rank": 2, - "score": 78, + "score": 84, "gold": 0, - "silver": 6, - "bronze": 1 + "silver": 8, + "bronze": 0 }, { "name": "theine", "rank": 3, - "score": 73, + "score": 80, "gold": 1, - "silver": 3, - "bronze": 4 + "silver": 2, + "bronze": 7 }, { "name": "sieve", "rank": 4, - "score": 72, + "score": 70, "gold": 1, "silver": 4, - "bronze": 4 + "bronze": 3 }, { "name": "clock", "rank": 5, - "score": 63, + "score": 64, "gold": 1, "silver": 3, "bronze": 2 diff --git a/benchmarks/gocachemark_results.md b/benchmarks/gocachemark_results.md index e5056f2..329b003 100644 --- a/benchmarks/gocachemark_results.md +++ b/benchmarks/gocachemark_results.md @@ -1,7 +1,7 @@ # gocachemark Results ``` -Command: gocachemark -caches fido,otter,clock,theine,sieve,freelru-sync -outdir /var/folders/v8/x1bfz_f51ks821ly48ckh1640000gn/T/gocachemark-603065090 +Command: gocachemark -caches fido,otter,clock,theine,sieve,freelru-sync -outdir /var/folders/v8/x1bfz_f51ks821ly48ckh1640000gn/T/gocachemark-3006596758 Environment: darwin/arm64, 16 CPUs, go1.25.5 ``` @@ -15,8 +15,8 @@ Environment: darwin/arm64, 16 CPUs, go1.25.5 | sieve | 55.63% | 56.95% | 58.25% | 59.30% | 60.59% | 58.143% | | clock | 55.09% | 56.68% | 58.14% | 59.42% | 60.57% | 57.979% | | freelru-sync | 54.90% | 56.52% | 57.94% | 59.35% | 60.53% | 57.848% | -| otter | 54.69% | 55.12% | 55.96% | 57.17% | 59.06% | 56.402% | -| theine | 54.31% | 55.34% | 55.34% | 55.69% | 59.25% | 55.989% | +| otter | 54.73% | 55.17% | 55.89% | 57.24% | 59.04% | 56.414% | +| theine | 54.27% | 55.18% | 55.13% | 55.35% | 59.27% | 55.840% | **Winner:** fido (+0.365% vs sieve) @@ -25,26 +25,26 @@ Environment: darwin/arm64, 16 CPUs, go1.25.5 | Cache | 16K | 32K | 64K | 128K | 256K | Avg | |---------------|--------|--------|--------|--------|--------|---------| | fido | 58.80% | 66.99% | 73.85% | 79.51% | 83.33% | 72.495% | -| theine | 57.07% | 64.87% | 70.51% | 75.07% | 81.23% | 69.750% | +| theine | 57.06% | 64.86% | 70.45% | 74.99% | 81.26% | 69.725% | | sieve | 52.10% | 61.56% | 70.02% | 77.17% | 82.45% | 68.659% | | freelru-sync | 50.93% | 59.92% | 69.44% | 77.58% | 83.12% | 68.198% | | clock | 50.23% | 59.80% | 68.95% | 76.76% | 82.68% | 67.683% | -| otter | 34.70% | 43.80% | 63.64% | 73.82% | 80.57% | 59.308% | +| otter | 34.82% | 45.66% | 63.89% | 73.86% | 80.58% | 59.761% | -**Winner:** fido (+2.746% vs theine) +**Winner:** fido (+2.771% vs theine) ### [zipf] Zipf | Cache | 16K | 32K | 64K | 128K | 256K | Avg | |---------------|--------|--------|--------|--------|--------|---------| -| theine | 63.21% | 74.20% | 87.12% | 95.01% | 95.01% | 82.911% | +| theine | 63.11% | 74.17% | 87.14% | 95.01% | 95.01% | 82.889% | +| otter | 61.81% | 73.30% | 86.67% | 95.01% | 95.01% | 82.362% | | fido | 61.90% | 72.83% | 86.97% | 95.01% | 95.01% | 82.344% | | sieve | 61.56% | 73.07% | 87.00% | 95.01% | 95.01% | 82.331% | -| otter | 61.24% | 73.28% | 86.68% | 95.01% | 95.01% | 82.246% | | clock | 56.54% | 70.48% | 86.60% | 95.01% | 95.01% | 80.729% | | freelru-sync | 55.57% | 69.55% | 86.11% | 95.01% | 95.01% | 80.251% | -**Winner:** theine (+0.567% vs fido) +**Winner:** theine (+0.527% vs otter) ### [twitter] Twitter @@ -54,8 +54,8 @@ Environment: darwin/arm64, 16 CPUs, go1.25.5 | sieve | 80.97% | 82.96% | 84.60% | 86.08% | 86.77% | 84.277% | | freelru-sync | 80.37% | 82.56% | 84.50% | 86.18% | 86.77% | 84.077% | | clock | 80.03% | 82.28% | 84.26% | 86.00% | 86.77% | 83.868% | -| otter | 79.69% | 81.44% | 83.91% | 85.75% | 86.77% | 83.511% | -| theine | 80.23% | 81.60% | 82.56% | 84.17% | 86.77% | 83.067% | +| otter | 79.61% | 81.48% | 83.95% | 85.75% | 86.77% | 83.514% | +| theine | 80.26% | 81.51% | 82.46% | 84.13% | 86.77% | 83.029% | **Winner:** fido (+0.402% vs sieve) @@ -64,13 +64,13 @@ Environment: darwin/arm64, 16 CPUs, go1.25.5 | Cache | 16K | 32K | 64K | 128K | 256K | Avg | |---------------|--------|--------|--------|--------|--------|---------| | fido | 23.09% | 27.51% | 32.61% | 38.53% | 44.60% | 33.269% | -| theine | 21.69% | 26.68% | 31.82% | 37.62% | 43.99% | 32.360% | +| theine | 22.21% | 26.70% | 31.76% | 37.62% | 43.94% | 32.446% | | sieve | 20.48% | 24.96% | 30.43% | 36.93% | 43.96% | 31.351% | -| otter | 19.93% | 23.78% | 28.90% | 35.27% | 42.65% | 30.106% | +| otter | 19.37% | 23.76% | 28.94% | 35.87% | 43.01% | 30.188% | | clock | 15.72% | 19.62% | 24.79% | 31.71% | 40.20% | 26.408% | | freelru-sync | 15.33% | 19.25% | 24.37% | 31.12% | 39.53% | 25.920% | -**Winner:** fido (+0.909% vs theine) +**Winner:** fido (+0.823% vs theine) ### [thesios-block] Thesios Block @@ -80,8 +80,8 @@ Environment: darwin/arm64, 16 CPUs, go1.25.5 | clock | 24.28% | 24.76% | 25.20% | 25.69% | 26.28% | 25.242% | | freelru-sync | 24.18% | 24.66% | 25.07% | 25.50% | 25.97% | 25.075% | | fido | 23.26% | 23.99% | 24.64% | 25.57% | 26.74% | 24.841% | -| theine | 23.87% | 23.85% | 24.49% | 24.50% | 25.05% | 24.352% | -| otter | 23.39% | 23.83% | 23.82% | 24.65% | 25.02% | 24.142% | +| theine | 23.79% | 23.96% | 24.45% | 24.44% | 25.25% | 24.377% | +| otter | 23.63% | 23.55% | 24.39% | 24.72% | 25.07% | 24.271% | **Winner:** sieve (+0.074% vs clock) @@ -92,9 +92,9 @@ Environment: darwin/arm64, 16 CPUs, go1.25.5 | fido | 91.55% | 92.88% | 94.25% | 95.03% | 95.30% | 93.803% | | freelru-sync | 91.30% | 92.03% | 93.73% | 94.85% | 95.31% | 93.444% | | sieve | 91.29% | 92.04% | 93.70% | 94.84% | 95.26% | 93.425% | -| theine | 91.19% | 92.33% | 93.56% | 94.61% | 95.03% | 93.342% | +| theine | 90.92% | 92.32% | 93.51% | 94.59% | 95.02% | 93.273% | | clock | 90.96% | 91.56% | 92.57% | 94.17% | 95.17% | 92.887% | -| otter | 90.42% | 91.61% | 92.51% | 92.94% | 94.68% | 92.432% | +| otter | 90.62% | 91.67% | 92.52% | 92.95% | 94.59% | 92.468% | **Winner:** fido (+0.359% vs freelru-sync) @@ -106,8 +106,8 @@ Environment: darwin/arm64, 16 CPUs, go1.25.5 | sieve | 81.87% | 82.98% | 83.82% | 84.06% | 84.34% | 83.416% | | fido | 82.02% | 82.96% | 83.63% | 83.90% | 84.14% | 83.330% | | freelru-sync | 82.45% | 82.92% | 83.35% | 83.69% | 84.11% | 83.303% | -| theine | 81.79% | 81.75% | 83.13% | 83.00% | 84.22% | 82.777% | -| otter | 81.17% | 81.42% | 82.27% | 82.84% | 83.92% | 82.324% | +| theine | 81.79% | 81.59% | 83.08% | 83.00% | 84.21% | 82.734% | +| otter | 81.14% | 81.42% | 82.24% | 82.84% | 83.93% | 82.314% | **Winner:** clock (+0.031% vs sieve) @@ -117,8 +117,8 @@ Environment: darwin/arm64, 16 CPUs, go1.25.5 |---------------|--------|--------|--------|--------|--------|---------| | fido | 14.76% | 17.39% | 20.63% | 24.74% | 29.99% | 21.499% | | sieve | 13.80% | 16.08% | 18.89% | 22.74% | 27.65% | 19.831% | -| theine | 14.10% | 16.71% | 19.57% | 22.41% | 26.04% | 19.766% | -| otter | 13.18% | 15.41% | 17.97% | 21.24% | 24.66% | 18.491% | +| theine | 14.11% | 16.59% | 19.55% | 22.48% | 26.03% | 19.752% | +| otter | 13.17% | 15.39% | 18.21% | 21.34% | 25.11% | 18.645% | | clock | 9.81% | 13.35% | 17.80% | 22.63% | 27.70% | 18.258% | | freelru-sync | 9.67% | 12.97% | 17.39% | 22.26% | 27.32% | 17.924% | @@ -130,33 +130,34 @@ Environment: darwin/arm64, 16 CPUs, go1.25.5 | Cache | Get ns | Get alloc | Set ns | Set alloc | SetEvict ns | SetEvict alloc | Avg ns | |---------------|--------|-----------|--------|-----------|-------------|----------------|----------| -| fido | 10 | 0 | 16 | 0 | 117 | 1 | 13.000 | -| clock | 15 | 0 | 21 | 0 | 97 | 2 | 18.000 | +| fido | 10 | 0 | 16 | 0 | 118 | 1 | 13.000 | +| clock | 16 | 0 | 22 | 0 | 98 | 2 | 19.000 | | freelru-sync | 24 | 0 | 24 | 0 | 36 | 0 | 24.000 | -| sieve | 23 | 0 | 46 | 0 | 191 | 3 | 34.500 | -| otter | 36 | 0 | 152 | 1 | 161 | 1 | 94.000 | -| theine | 84 | 1 | 121 | 0 | 203 | 0 | 102.500 | +| sieve | 23 | 0 | 45 | 0 | 191 | 3 | 34.000 | +| otter | 36 | 0 | 134 | 1 | 161 | 1 | 85.000 | +| theine | 85 | 1 | 403 | 0 | 490 | 2 | 244.000 | - winner: fido (+38.462% vs clock) + winner: fido (+46.154% vs clock) ### [int] Int Keys | Cache | Get ns | Get alloc | Set ns | Set alloc | SetEvict ns | SetEvict alloc | Avg ns | |---------------|--------|-----------|--------|-----------|-------------|----------------|----------| -| fido | 8 | 0 | 14 | 0 | 100 | 1 | 11.000 | -| otter | 33 | 0 | 133 | 1 | 154 | 1 | 83.000 | -| theine | 79 | 1 | 109 | 0 | 182 | 0 | 94.000 | +| fido | 8 | 0 | 14 | 0 | 99 | 1 | 11.000 | +| otter | 32 | 0 | 133 | 1 | 154 | 1 | 82.500 | +| theine | 80 | 1 | 348 | 0 | 445 | 2 | 214.000 | - winner: fido (+654.545% vs otter) + winner: fido (+650.000% vs otter) ### [getorset] GetOrSet | Cache | GetOrSet ns | GetOrSet alloc | |---------------|-------------|----------------| -| fido | 10 | 0 | -| otter | 55 | 1 | +| fido | 11 | 0 | +| otter | 54 | 1 | +| theine | 164 | 4 | - winner: fido (+450.000% vs otter) + winner: fido (+390.909% vs otter) ## Throughput Benchmarks @@ -164,80 +165,81 @@ Environment: darwin/arm64, 16 CPUs, go1.25.5 | Cache | 1T | 8T | 16T | 32T | Avg | |---------------|-----------|-----------|-----------|-----------|-----------| -| fido | 42.61M | 338.51M | 554.21M | 571.60M | 376.73M | -| otter | 18.82M | 138.19M | 303.14M | 302.92M | 190.77M | -| theine | 8.89M | 125.26M | 212.17M | 216.88M | 140.80M | -| sieve | 34.45M | 7.11M | 6.13M | 6.82M | 13.63M | -| clock | 35.35M | 6.32M | 5.45M | 6.13M | 13.31M | -| freelru-sync | 23.31M | 6.79M | 6.27M | 7.25M | 10.90M | +| fido | 40.34M | 336.12M | 551.50M | 553.14M | 370.27M | +| otter | 18.80M | 148.08M | 273.82M | 287.05M | 181.94M | +| theine | 8.34M | 118.88M | 199.65M | 192.54M | 129.85M | +| clock | 33.84M | 6.03M | 5.27M | 5.98M | 12.78M | +| sieve | 29.88M | 5.84M | 5.76M | 6.68M | 12.04M | +| freelru-sync | 23.56M | 6.65M | 6.00M | 7.36M | 10.89M | - winner: fido (+97.485% vs otter) + winner: fido (+103.518% vs otter) ### [string-set-throughput] String Set | Cache | 1T | 8T | 16T | 32T | Avg | |---------------|-----------|-----------|-----------|-----------|-----------| -| fido | 24.29M | 87.51M | 133.14M | 134.44M | 94.85M | -| clock | 24.33M | 5.90M | 5.21M | 5.98M | 10.36M | -| freelru-sync | 21.38M | 6.50M | 6.14M | 6.76M | 10.20M | -| sieve | 14.75M | 5.11M | 4.80M | 4.96M | 7.41M | -| theine | 4.93M | 4.37M | 3.61M | 3.26M | 4.04M | -| otter | 6.50M | 2.85M | 2.68M | 2.72M | 3.69M | +| fido | 23.14M | 86.17M | 108.92M | 62.39M | 70.15M | +| clock | 23.53M | 5.91M | 5.24M | 5.88M | 10.14M | +| freelru-sync | 21.26M | 6.24M | 5.29M | 6.38M | 9.79M | +| sieve | 13.46M | 4.90M | 4.64M | 4.92M | 6.98M | +| otter | 4.26M | 2.90M | 2.65M | 2.71M | 3.13M | +| theine | 1.82M | 2.92M | 2.44M | 2.24M | 2.35M | - winner: fido (+815.940% vs clock) + winner: fido (+591.750% vs clock) ### [int-get-throughput] Int Get | Cache | 1T | 8T | 16T | 32T | Avg | |---------------|-----------|-----------|-----------|-----------|-----------| -| fido | 86.33M | 680.47M | 1106.45M | 1101.18M | 743.61M | -| otter | 27.39M | 179.27M | 438.93M | 443.37M | 272.24M | -| theine | 10.76M | 111.71M | 277.34M | 287.81M | 171.90M | +| fido | 70.00M | 667.63M | 1098.30M | 1071.37M | 726.82M | +| otter | 27.57M | 213.55M | 429.23M | 419.90M | 272.56M | +| theine | 10.56M | 159.15M | 261.55M | 262.25M | 173.38M | - winner: fido (+173.143% vs otter) + winner: fido (+166.664% vs otter) ### [int-set-throughput] Int Set | Cache | 1T | 8T | 16T | 32T | Avg | |---------------|-----------|-----------|-----------|-----------|-----------| -| fido | 51.11M | 137.07M | 207.52M | 164.70M | 140.10M | -| theine | 7.51M | 4.90M | 3.82M | 3.56M | 4.95M | -| otter | 6.76M | 2.93M | 2.68M | 2.74M | 3.78M | +| fido | 50.72M | 145.43M | 142.02M | 145.76M | 120.98M | +| otter | 7.49M | 2.92M | 2.72M | 2.74M | 3.97M | +| theine | 2.70M | 3.43M | 2.83M | 2.44M | 2.85M | - winner: fido (+2732.500% vs theine) + winner: fido (+2949.069% vs otter) ### [getorset-throughput] GetOrSet | Cache | 1T | 8T | 16T | 32T | Avg | |---------------|-----------|-----------|-----------|-----------|-----------| -| fido | 39.66M | 264.53M | 473.30M | 495.43M | 318.23M | -| otter | 12.23M | 96.61M | 172.57M | 168.64M | 112.51M | +| fido | 35.65M | 229.12M | 430.98M | 464.85M | 290.15M | +| otter | 11.20M | 94.77M | 163.98M | 157.99M | 106.98M | +| theine | 2.75M | 17.08M | 30.79M | 39.23M | 22.46M | - winner: fido (+182.838% vs otter) + winner: fido (+171.207% vs otter) ## Memory Benchmarks -Baseline (map[string][]byte): 35.83 MB +Baseline (map[string][]byte): 35.97 MB | Cache | Items Stored | Memory (MB) | Overhead vs map (bytes/item) | |---------------|--------------|-------------|------------------------------| -| freelru-sync | 32768 | 35.19 | -20 | -| otter | 32768 | 36.31 | 15 | -| clock | 32768 | 37.03 | 38 | -| fido | 32768 | 37.39 | 49 | -| theine | 32768 | 38.46 | 83 | -| sieve | 32768 | 39.53 | 118 | +| freelru-sync | 32768 | 35.33 | -20 | +| otter | 32768 | 36.44 | 15 | +| clock | 32768 | 37.13 | 37 | +| fido | 32768 | 37.51 | 49 | +| theine | 32768 | 38.61 | 84 | +| sieve | 32768 | 39.66 | 118 | - winner: freelru-sync (+3.187% vs otter) + winner: freelru-sync (+3.140% vs otter) ## Overall Rankings | Rank | Cache | Score | Gold | Silver | Bronze | |------|---------------|-------|------|--------|--------| -| 1 | fido | 160 | 14 | 1 | 1 | -| 2 | otter | 78 | 0 | 6 | 1 | -| 3 | theine | 73 | 1 | 3 | 4 | -| 4 | sieve | 72 | 1 | 4 | 4 | -| 5 | clock | 63 | 1 | 3 | 2 | +| 1 | fido | 158 | 14 | 0 | 2 | +| 2 | otter | 84 | 0 | 8 | 0 | +| 3 | theine | 80 | 1 | 2 | 7 | +| 4 | sieve | 70 | 1 | 4 | 3 | +| 5 | clock | 64 | 1 | 3 | 2 | | 6 | freelru-sync | 57 | 1 | 1 | 4 | diff --git a/bloom.go b/bloom.go index 79d42a3..8b691e1 100644 --- a/bloom.go +++ b/bloom.go @@ -60,9 +60,7 @@ func (b *bloomFilter) Contains(h uint64) bool { } func (b *bloomFilter) Reset() { - for i := range b.data { - b.data[i] = 0 - } + clear(b.data) b.entries = 0 } diff --git a/memory.go b/memory.go index 2cedc6d..f0fbf77 100644 --- a/memory.go +++ b/memory.go @@ -25,7 +25,6 @@ type Cache[K comparable, V any] struct { flights *xsync.Map[K, *flightCall[V]] memory *s3fifo[K, V] defaultTTL time.Duration - noExpiry bool } // flightCall holds an in-flight computation for singleflight deduplication. @@ -39,7 +38,7 @@ type flightCall[V any] struct { // New creates an in-memory cache. func New[K comparable, V any](opts ...Option) *Cache[K, V] { - cfg := defaultConfig() + cfg := &config{size: 16384} for _, opt := range opts { opt(cfg) } @@ -48,7 +47,6 @@ func New[K comparable, V any](opts ...Option) *Cache[K, V] { flights: xsync.NewMap[K, *flightCall[V]](), memory: newS3FIFO[K, V](cfg), defaultTTL: cfg.defaultTTL, - noExpiry: cfg.defaultTTL == 0, } } @@ -60,11 +58,7 @@ func (c *Cache[K, V]) Get(key K) (V, bool) { // Set stores a value using the default TTL specified at cache creation. // If no default TTL was set, the entry never expires. func (c *Cache[K, V]) Set(key K, value V) { - if c.noExpiry { - c.memory.set(key, value, 0) - return - } - c.memory.set(key, value, timeToSec(c.expiry(0))) + c.SetTTL(key, value, c.defaultTTL) } // SetTTL stores a value with an explicit TTL. @@ -83,15 +77,15 @@ func (c *Cache[K, V]) Delete(key K) { c.memory.del(key) } -// GetSet returns cached value or calls loader to compute it. +// Fetch returns cached value or calls loader to compute it. // Concurrent calls for the same key share one loader invocation. // Computed values are stored with the default TTL. -func (c *Cache[K, V]) GetSet(key K, loader func() (V, error)) (V, error) { +func (c *Cache[K, V]) Fetch(key K, loader func() (V, error)) (V, error) { return c.getSet(key, loader, 0) } -// GetSetTTL is like GetSet but stores computed values with an explicit TTL. -func (c *Cache[K, V]) GetSetTTL(key K, loader func() (V, error), ttl time.Duration) (V, error) { +// FetchTTL is like Fetch but stores computed values with an explicit TTL. +func (c *Cache[K, V]) FetchTTL(key K, ttl time.Duration, loader func() (V, error)) (V, error) { return c.getSet(key, loader, ttl) } @@ -144,19 +138,11 @@ func (c *Cache[K, V]) Flush() int { return c.memory.flush() } -func (c *Cache[K, V]) expiry(ttl time.Duration) time.Time { - return calculateExpiry(ttl, c.defaultTTL) -} - type config struct { size int defaultTTL time.Duration } -func defaultConfig() *config { - return &config{size: 16384} -} - // Option configures a Cache. type Option func(*config) diff --git a/memory_race_test.go b/memory_race_test.go index 83d48ff..5cddec9 100644 --- a/memory_race_test.go +++ b/memory_race_test.go @@ -13,7 +13,7 @@ import ( // The seqlock value storage uses intentional "benign races" that the // race detector cannot understand. -func TestCache_GetSet_CacheHitDuringSingleflight(t *testing.T) { +func TestCache_Fetch_CacheHitDuringSingleflight(t *testing.T) { cache := New[string, int](Size(1000)) var wg sync.WaitGroup @@ -21,13 +21,13 @@ func TestCache_GetSet_CacheHitDuringSingleflight(t *testing.T) { // Start first loader that's slow wg.Go(func() { - if _, err := cache.GetSet("key1", func() (int, error) { + if _, err := cache.Fetch("key1", func() (int, error) { loaderCalls.Add(1) // While loader is running, another goroutine populates cache time.Sleep(100 * time.Millisecond) return 42, nil }); err != nil { - t.Errorf("GetSet error: %v", err) + t.Errorf("Fetch error: %v", err) } }) @@ -39,12 +39,12 @@ func TestCache_GetSet_CacheHitDuringSingleflight(t *testing.T) { // Start second loader that should wait for first wg.Go(func() { - val, err := cache.GetSet("key1", func() (int, error) { + val, err := cache.Fetch("key1", func() (int, error) { loaderCalls.Add(1) return 77, nil }) if err != nil { - t.Errorf("GetSet error: %v", err) + t.Errorf("Fetch error: %v", err) return } // Second should get either 99 (from cache) or 42 (from first loader) diff --git a/memory_test.go b/memory_test.go index 975bfe0..09d8000 100644 --- a/memory_test.go +++ b/memory_test.go @@ -420,7 +420,7 @@ func TestCache_Set_NoDefaultTTL(t *testing.T) { } } -func TestCache_GetSet_Basic(t *testing.T) { +func TestCache_Fetch_Basic(t *testing.T) { cache := New[string, int]() loaderCalls := 0 @@ -430,40 +430,40 @@ func TestCache_GetSet_Basic(t *testing.T) { } // First call - should call loader - val, err := cache.GetSet("key1", loader) + val, err := cache.Fetch("key1", loader) if err != nil { - t.Fatalf("GetSet error: %v", err) + t.Fatalf("Fetch error: %v", err) } if val != 42 { - t.Errorf("GetSet value = %d; want 42", val) + t.Errorf("Fetch value = %d; want 42", val) } if loaderCalls != 1 { t.Errorf("loader calls = %d; want 1", loaderCalls) } // Second call - should use cached value, not call loader - val, err = cache.GetSet("key1", loader) + val, err = cache.Fetch("key1", loader) if err != nil { - t.Fatalf("GetSet error: %v", err) + t.Fatalf("Fetch error: %v", err) } if val != 42 { - t.Errorf("GetSet value = %d; want 42", val) + t.Errorf("Fetch value = %d; want 42", val) } if loaderCalls != 1 { t.Errorf("loader calls = %d; want 1 (should use cache)", loaderCalls) } } -func TestCache_GetSet_LoaderError(t *testing.T) { +func TestCache_Fetch_LoaderError(t *testing.T) { cache := New[string, int]() loader := func() (int, error) { return 0, fmt.Errorf("loader error") } - _, err := cache.GetSet("key1", loader) + _, err := cache.Fetch("key1", loader) if err == nil { - t.Fatal("GetSet should return error from loader") + t.Fatal("Fetch should return error from loader") } // Value should not be cached @@ -473,7 +473,7 @@ func TestCache_GetSet_LoaderError(t *testing.T) { } } -func TestCache_GetSet_ThunderingHerd(t *testing.T) { +func TestCache_Fetch_ThunderingHerd(t *testing.T) { cache := New[string, int]() var loaderCalls int32 @@ -491,7 +491,7 @@ func TestCache_GetSet_ThunderingHerd(t *testing.T) { return 42, nil } - // Launch many concurrent GetSet calls for the same key + // Launch many concurrent Fetch calls for the same key var wg sync.WaitGroup results := make([]int, 100) errors := make([]error, 100) @@ -500,7 +500,7 @@ func TestCache_GetSet_ThunderingHerd(t *testing.T) { wg.Add(1) go func(idx int) { defer wg.Done() - results[idx], errors[idx] = cache.GetSet("key1", loader) + results[idx], errors[idx] = cache.Fetch("key1", loader) }(i) } @@ -522,7 +522,7 @@ func TestCache_GetSet_ThunderingHerd(t *testing.T) { } } -func TestCache_GetSet_WithTTL(t *testing.T) { +func TestCache_Fetch_WithTTL(t *testing.T) { cache := New[string, int]() loaderCalls := 0 @@ -532,31 +532,31 @@ func TestCache_GetSet_WithTTL(t *testing.T) { } // First call with short TTL (1 second granularity) - val, err := cache.GetSetTTL("key1", loader, 1*time.Second) + val, err := cache.FetchTTL("key1", 1*time.Second, loader) if err != nil { - t.Fatalf("GetSet error: %v", err) + t.Fatalf("Fetch error: %v", err) } if val != 10 { - t.Errorf("first GetSet value = %d; want 10", val) + t.Errorf("first Fetch value = %d; want 10", val) } // Wait for TTL to expire time.Sleep(2 * time.Second) // Second call - should call loader again (cache expired) - val, err = cache.GetSetTTL("key1", loader, 1*time.Second) + val, err = cache.FetchTTL("key1", 1*time.Second, loader) if err != nil { - t.Fatalf("GetSet error: %v", err) + t.Fatalf("Fetch error: %v", err) } if val != 20 { - t.Errorf("second GetSet value = %d; want 20", val) + t.Errorf("second Fetch value = %d; want 20", val) } if loaderCalls != 2 { t.Errorf("loader calls = %d; want 2", loaderCalls) } } -func TestCache_GetSet_IntKeys(t *testing.T) { +func TestCache_Fetch_IntKeys(t *testing.T) { cache := New[int, int](Size(1000)) var loaderCalls int32 @@ -571,12 +571,12 @@ func TestCache_GetSet_IntKeys(t *testing.T) { for i := range 50 { wg.Go(func() { // All goroutines request the same key - val, err := cache.GetSet(123, loader) + val, err := cache.Fetch(123, loader) if err != nil { - t.Errorf("GetSet error: %v", err) + t.Errorf("Fetch error: %v", err) } if val != 42 { - t.Errorf("GetSet value = %d; want 42", val) + t.Errorf("Fetch value = %d; want 42", val) } }) // Stagger slightly to ensure overlap @@ -593,12 +593,12 @@ func TestCache_GetSet_IntKeys(t *testing.T) { // Verify different int keys work independently loaderCalls = 0 for i := range 10 { - _, err := cache.GetSet(i, func() (int, error) { + _, err := cache.Fetch(i, func() (int, error) { atomic.AddInt32(&loaderCalls, 1) return i * 10, nil }) if err != nil { - t.Fatalf("GetSet key %d error: %v", i, err) + t.Fatalf("Fetch key %d error: %v", i, err) } } @@ -651,30 +651,30 @@ func TestCache_CapacityEfficiency_StringKeys(t *testing.T) { } } -// TestCache_GetSet_CacheHitDuringSingleflight is in memory_race_test.go +// TestCache_Fetch_CacheHitDuringSingleflight is in memory_race_test.go // It tests concurrent cache access which triggers seqlock races. -func TestCache_GetSet_RaceCondition(t *testing.T) { +func TestCache_Fetch_RaceCondition(t *testing.T) { // Test the path where cache is populated between first check and singleflight cache := New[string, int](Size(1000)) var wg sync.WaitGroup - // Run many concurrent GetSets with a mix of slow and fast loaders + // Run many concurrent Fetchs with a mix of slow and fast loaders for i := range 20 { wg.Add(1) go func(idx int) { defer wg.Done() key := fmt.Sprintf("key%d", idx%5) // Only 5 unique keys - val, err := cache.GetSet(key, func() (int, error) { + val, err := cache.Fetch(key, func() (int, error) { if idx%3 == 0 { time.Sleep(10 * time.Millisecond) } return idx * 10, nil }) if err != nil { - t.Errorf("GetSet error: %v", err) + t.Errorf("Fetch error: %v", err) } if val < 0 { t.Errorf("unexpected value: %d", val) @@ -685,9 +685,9 @@ func TestCache_GetSet_RaceCondition(t *testing.T) { wg.Wait() } -// TestCache_GetSet_MemoryHitAfterSingleflightAcquire tests the path where +// TestCache_Fetch_MemoryHitAfterSingleflightAcquire tests the path where // the cache is populated between winning singleflight and checking cache again. -func TestCache_GetSet_MemoryHitAfterSingleflightAcquire(t *testing.T) { +func TestCache_Fetch_MemoryHitAfterSingleflightAcquire(t *testing.T) { // This is tricky to test because the window is very small. // We use a contrived scenario with concurrent access. cache := New[string, int](Size(100)) @@ -706,12 +706,12 @@ func TestCache_GetSet_MemoryHitAfterSingleflightAcquire(t *testing.T) { defer done.Done() started.Done() // Signal that we've started - if _, err := cache.GetSet(key, func() (int, error) { + if _, err := cache.Fetch(key, func() (int, error) { // Wait long enough for the second Set to happen time.Sleep(50 * time.Millisecond) return 1, nil }); err != nil { - t.Errorf("GetSet error: %v", err) + t.Errorf("Fetch error: %v", err) } }() @@ -735,28 +735,28 @@ func TestCache_GetSet_MemoryHitAfterSingleflightAcquire(t *testing.T) { } } -// TestCache_GetSet_WithDefaultTTL tests GetSet using the default TTL. -func TestCache_GetSet_WithDefaultTTL(t *testing.T) { +// TestCache_Fetch_WithDefaultTTL tests Fetch using the default TTL. +func TestCache_Fetch_WithDefaultTTL(t *testing.T) { cache := New[string, int](TTL(time.Hour)) - val, err := cache.GetSet("key1", func() (int, error) { + val, err := cache.Fetch("key1", func() (int, error) { return 42, nil }) if err != nil { - t.Fatalf("GetSet error: %v", err) + t.Fatalf("Fetch error: %v", err) } if val != 42 { - t.Errorf("GetSet value = %d; want 42", val) + t.Errorf("Fetch value = %d; want 42", val) } } -// TestCache_GetSet_DoubleCheckPath attempts to hit the double-check cache hit path. +// TestCache_Fetch_DoubleCheckPath attempts to hit the double-check cache hit path. // This path is triggered when: // 1. First check misses (no cache hit) // 2. We win the singleflight (not loaded) // 3. Another call populated the cache before our double-check // 4. Double-check finds the value -func TestCache_GetSet_DoubleCheckPath(t *testing.T) { +func TestCache_Fetch_DoubleCheckPath(t *testing.T) { var hitCount int for iteration := range 1000 { cache := New[string, int](Size(100)) @@ -770,11 +770,11 @@ func TestCache_GetSet_DoubleCheckPath(t *testing.T) { // Goroutine 1: Will try to win singleflight go func() { defer wg.Done() - if _, err := cache.GetSet(key, func() (int, error) { + if _, err := cache.Fetch(key, func() (int, error) { loaderCalled.Store(true) return 1, nil }); err != nil { - t.Errorf("GetSet error: %v", err) + t.Errorf("Fetch error: %v", err) } }() diff --git a/perf_test.go b/perf_test.go index 0757de3..4304cc5 100644 --- a/perf_test.go +++ b/perf_test.go @@ -2,36 +2,6 @@ package fido -import ( - "testing" - "time" -) - -func TestCache_ReadPerformance(t *testing.T) { - cache := New[int, int]() - - // Populate cache - for i := range 10000 { - cache.Set(i, i) - } - - // Warm up - for i := range 1000 { - cache.Get(i % 10000) - } - - // Measure read performance - const iterations = 100000 - start := time.Now() - for i := range iterations { - cache.Get(i % 10000) - } - elapsed := time.Since(start) - nsPerOp := float64(elapsed.Nanoseconds()) / float64(iterations) - - const maxNsPerOp = 80.0 - if nsPerOp > maxNsPerOp { - t.Errorf("single-threaded read performance: %.2f ns/op exceeds %.0f ns/op threshold", nsPerOp, maxNsPerOp) - } - t.Logf("single-threaded read performance: %.2f ns/op", nsPerOp) -} +// Performance tests removed - CI environments have variable performance +// characteristics that make hard-coded thresholds unreliable. +// Use `go test -bench=.` for performance benchmarking instead. diff --git a/persistent.go b/persistent.go index 8a46469..bdf66cc 100644 --- a/persistent.go +++ b/persistent.go @@ -22,7 +22,7 @@ type TieredCache[K comparable, V any] struct { // NewTiered creates a cache backed by the given store. func NewTiered[K comparable, V any](store Store[K, V], opts ...Option) (*TieredCache[K, V], error) { - cfg := defaultConfig() + cfg := &config{size: 16384} for _, opt := range opts { opt(cfg) } @@ -66,10 +66,6 @@ func (c *TieredCache[K, V]) Get(ctx context.Context, key K) (V, bool, error) { return val, true, nil } -func (c *TieredCache[K, V]) expiry(ttl time.Duration) time.Time { - return calculateExpiry(ttl, c.defaultTTL) -} - // Set stores to memory first (always), then persistence. // Uses the default TTL specified at cache creation. func (c *TieredCache[K, V]) Set(ctx context.Context, key K, value V) error { @@ -79,7 +75,7 @@ func (c *TieredCache[K, V]) Set(ctx context.Context, key K, value V) error { // SetTTL stores to memory first (always), then persistence with explicit TTL. // A zero or negative TTL means the entry never expires. func (c *TieredCache[K, V]) SetTTL(ctx context.Context, key K, value V, ttl time.Duration) error { - expiry := c.expiry(ttl) + expiry := calculateExpiry(ttl, c.defaultTTL) if err := c.Store.ValidateKey(key); err != nil { return err @@ -102,7 +98,7 @@ func (c *TieredCache[K, V]) SetAsync(ctx context.Context, key K, value V) error // SetAsyncTTL stores to memory synchronously, persistence asynchronously with explicit TTL. // Persistence errors are logged, not returned. func (c *TieredCache[K, V]) SetAsyncTTL(ctx context.Context, key K, value V, ttl time.Duration) error { - expiry := c.expiry(ttl) + expiry := calculateExpiry(ttl, c.defaultTTL) if err := c.Store.ValidateKey(key); err != nil { return err @@ -121,14 +117,14 @@ func (c *TieredCache[K, V]) SetAsyncTTL(ctx context.Context, key K, value V, ttl return nil } -// GetSet returns cached value or calls loader. Concurrent calls share one loader. +// Fetch returns cached value or calls loader. Concurrent calls share one loader. // Computed values are stored with the default TTL. -func (c *TieredCache[K, V]) GetSet(ctx context.Context, key K, loader func(context.Context) (V, error)) (V, error) { +func (c *TieredCache[K, V]) Fetch(ctx context.Context, key K, loader func(context.Context) (V, error)) (V, error) { return c.getSet(ctx, key, loader, 0) } -// GetSetTTL is like GetSet but stores computed values with an explicit TTL. -func (c *TieredCache[K, V]) GetSetTTL(ctx context.Context, key K, loader func(context.Context) (V, error), ttl time.Duration) (V, error) { +// FetchTTL is like Fetch but stores computed values with an explicit TTL. +func (c *TieredCache[K, V]) FetchTTL(ctx context.Context, key K, ttl time.Duration, loader func(context.Context) (V, error)) (V, error) { return c.getSet(ctx, key, loader, ttl) } @@ -193,11 +189,11 @@ func (c *TieredCache[K, V]) getSet(ctx context.Context, key K, loader func(conte return zero, err } - exp := c.expiry(ttl) + exp := calculateExpiry(ttl, c.defaultTTL) c.memory.set(key, val, timeToSec(exp)) if err := c.Store.Set(ctx, key, val, exp); err != nil { - slog.Warn("GetSet persistence failed", "key", key, "error", err) + slog.Warn("Fetch persistence failed", "key", key, "error", err) } call.val = val diff --git a/persistent_test.go b/persistent_test.go index caff211..0ae2cb3 100644 --- a/persistent_test.go +++ b/persistent_test.go @@ -268,7 +268,7 @@ func (m *injectingMockStore[K, V]) Get(ctx context.Context, key K) (v V, expiry return m.mockStore.Get(ctx, key) } -func TestTieredCache_GetSet_SecondMemoryCheck(t *testing.T) { +func TestTieredCache_Fetch_SecondMemoryCheck(t *testing.T) { // This test triggers the second memory check path in getSet (line 166-171) // by injecting a value into memory during the first store.Get call. store := newInjectingMockStore[string, int]() @@ -286,12 +286,12 @@ func TestTieredCache_GetSet_SecondMemoryCheck(t *testing.T) { ctx := context.Background() loaderCalled := false - val, err := cache.GetSet(ctx, "key1", func(context.Context) (int, error) { + val, err := cache.Fetch(ctx, "key1", func(context.Context) (int, error) { loaderCalled = true return 42, nil }) if err != nil { - t.Fatalf("GetSet failed: %v", err) + t.Fatalf("Fetch failed: %v", err) } // Should return injected value from second memory check @@ -1115,7 +1115,7 @@ func TestNewTiered_WithTTL_Behavior(t *testing.T) { } } -func TestTieredCache_GetSet_Basic(t *testing.T) { +func TestTieredCache_Fetch_Basic(t *testing.T) { store := newMockStore[string, int]() cache, err := NewTiered[string, int](store) if err != nil { @@ -1131,24 +1131,24 @@ func TestTieredCache_GetSet_Basic(t *testing.T) { } // First call - should call loader - val, err := cache.GetSet(ctx, "key1", loader) + val, err := cache.Fetch(ctx, "key1", loader) if err != nil { - t.Fatalf("GetSet error: %v", err) + t.Fatalf("Fetch error: %v", err) } if val != 42 { - t.Errorf("GetSet value = %d; want 42", val) + t.Errorf("Fetch value = %d; want 42", val) } if loaderCalls != 1 { t.Errorf("loader calls = %d; want 1", loaderCalls) } // Second call - should use cached value, not call loader - val, err = cache.GetSet(ctx, "key1", loader) + val, err = cache.Fetch(ctx, "key1", loader) if err != nil { - t.Fatalf("GetSet error: %v", err) + t.Fatalf("Fetch error: %v", err) } if val != 42 { - t.Errorf("GetSet value = %d; want 42", val) + t.Errorf("Fetch value = %d; want 42", val) } if loaderCalls != 1 { t.Errorf("loader calls = %d; want 1 (should use cache)", loaderCalls) @@ -1164,7 +1164,7 @@ func TestTieredCache_GetSet_Basic(t *testing.T) { } } -func TestTieredCache_GetSet_FromPersistence(t *testing.T) { +func TestTieredCache_Fetch_FromPersistence(t *testing.T) { store := newMockStore[string, int]() cache, err := NewTiered[string, int](store) if err != nil { @@ -1183,20 +1183,20 @@ func TestTieredCache_GetSet_FromPersistence(t *testing.T) { return 42, nil } - // GetSet should find value in persistence, not call loader - val, err := cache.GetSet(ctx, "key1", loader) + // Fetch should find value in persistence, not call loader + val, err := cache.Fetch(ctx, "key1", loader) if err != nil { - t.Fatalf("GetSet error: %v", err) + t.Fatalf("Fetch error: %v", err) } if val != 99 { - t.Errorf("GetSet value = %d; want 99 (from persistence)", val) + t.Errorf("Fetch value = %d; want 99 (from persistence)", val) } if loaderCalls != 0 { t.Errorf("loader calls = %d; want 0 (should use persistence)", loaderCalls) } } -func TestTieredCache_GetSet_LoaderError(t *testing.T) { +func TestTieredCache_Fetch_LoaderError(t *testing.T) { store := newMockStore[string, int]() cache, err := NewTiered[string, int](store) if err != nil { @@ -1209,9 +1209,9 @@ func TestTieredCache_GetSet_LoaderError(t *testing.T) { return 0, fmt.Errorf("loader error") } - _, err = cache.GetSet(ctx, "key1", loader) + _, err = cache.Fetch(ctx, "key1", loader) if err == nil { - t.Fatal("GetSet should return error from loader") + t.Fatal("Fetch should return error from loader") } // Value should not be cached in memory @@ -1230,7 +1230,7 @@ func TestTieredCache_GetSet_LoaderError(t *testing.T) { } } -func TestTieredCache_GetSet_ThunderingHerd(t *testing.T) { +func TestTieredCache_Fetch_ThunderingHerd(t *testing.T) { store := newMockStore[string, int]() cache, err := NewTiered[string, int](store) if err != nil { @@ -1254,7 +1254,7 @@ func TestTieredCache_GetSet_ThunderingHerd(t *testing.T) { return 42, nil } - // Launch many concurrent GetSet calls for the same key + // Launch many concurrent Fetch calls for the same key var wg sync.WaitGroup results := make([]int, 100) errors := make([]error, 100) @@ -1263,7 +1263,7 @@ func TestTieredCache_GetSet_ThunderingHerd(t *testing.T) { wg.Add(1) go func(idx int) { defer wg.Done() - results[idx], errors[idx] = cache.GetSet(ctx, "key1", loader) + results[idx], errors[idx] = cache.Fetch(ctx, "key1", loader) }(i) } @@ -1285,7 +1285,7 @@ func TestTieredCache_GetSet_ThunderingHerd(t *testing.T) { } } -func TestTieredCache_GetSet_WithTTL(t *testing.T) { +func TestTieredCache_Fetch_WithTTL(t *testing.T) { store := newMockStore[string, int]() cache, err := NewTiered[string, int](store) if err != nil { @@ -1301,31 +1301,31 @@ func TestTieredCache_GetSet_WithTTL(t *testing.T) { } // First call with short TTL (1 second granularity) - val, err := cache.GetSetTTL(ctx, "key1", loader, 1*time.Second) + val, err := cache.FetchTTL(ctx, "key1", 1*time.Second, loader) if err != nil { - t.Fatalf("GetSet error: %v", err) + t.Fatalf("Fetch error: %v", err) } if val != 10 { - t.Errorf("first GetSet value = %d; want 10", val) + t.Errorf("first Fetch value = %d; want 10", val) } // Wait for TTL to expire time.Sleep(2 * time.Second) // Second call - should call loader again (cache expired) - val, err = cache.GetSetTTL(ctx, "key1", loader, 1*time.Second) + val, err = cache.FetchTTL(ctx, "key1", 1*time.Second, loader) if err != nil { - t.Fatalf("GetSet error: %v", err) + t.Fatalf("Fetch error: %v", err) } if val != 20 { - t.Errorf("second GetSet value = %d; want 20", val) + t.Errorf("second Fetch value = %d; want 20", val) } if loaderCalls != 2 { t.Errorf("loader calls = %d; want 2", loaderCalls) } } -func TestTieredCache_GetSet_PersistenceFailure(t *testing.T) { +func TestTieredCache_Fetch_PersistenceFailure(t *testing.T) { store := newMockStore[string, int]() cache, err := NewTiered[string, int](store) if err != nil { @@ -1341,13 +1341,13 @@ func TestTieredCache_GetSet_PersistenceFailure(t *testing.T) { // Make persistence fail store.setFailSet(true) - // GetSet should still succeed (value in memory) - val, err := cache.GetSet(ctx, "key1", loader) + // Fetch should still succeed (value in memory) + val, err := cache.Fetch(ctx, "key1", loader) if err != nil { - t.Fatalf("GetSet should succeed even if persistence fails: %v", err) + t.Fatalf("Fetch should succeed even if persistence fails: %v", err) } if val != 42 { - t.Errorf("GetSet value = %d; want 42", val) + t.Errorf("Fetch value = %d; want 42", val) } // Value should be in memory @@ -1360,7 +1360,7 @@ func TestTieredCache_GetSet_PersistenceFailure(t *testing.T) { } } -func TestTieredCache_GetSet_KeyValidationError(t *testing.T) { +func TestTieredCache_Fetch_KeyValidationError(t *testing.T) { store := &validatingMockStore[string, int]{ mockStore: newMockStore[string, int](), } @@ -1377,10 +1377,10 @@ func TestTieredCache_GetSet_KeyValidationError(t *testing.T) { return 42, nil } - // GetSet with invalid key should return error - _, err = cache.GetSet(ctx, "invalid/key", loader) + // Fetch with invalid key should return error + _, err = cache.Fetch(ctx, "invalid/key", loader) if err == nil { - t.Error("GetSet with invalid key should return error") + t.Error("Fetch with invalid key should return error") } // Loader should not have been called @@ -1389,7 +1389,7 @@ func TestTieredCache_GetSet_KeyValidationError(t *testing.T) { } } -func TestTieredCache_GetSet_PersistenceLoadFailure(t *testing.T) { +func TestTieredCache_Fetch_PersistenceLoadFailure(t *testing.T) { store := newMockStore[string, int]() cache, err := NewTiered[string, int](store) if err != nil { @@ -1405,14 +1405,14 @@ func TestTieredCache_GetSet_PersistenceLoadFailure(t *testing.T) { // Make persistence load fail store.setFailGet(true) - // GetSet should return error when persistence load fails - _, err = cache.GetSet(ctx, "key1", loader) + // Fetch should return error when persistence load fails + _, err = cache.Fetch(ctx, "key1", loader) if err == nil { - t.Error("GetSet should return error when persistence load fails") + t.Error("Fetch should return error when persistence load fails") } } -func TestTieredCache_GetSet_PersistenceLoadFailure_InFlight(t *testing.T) { +func TestTieredCache_Fetch_PersistenceLoadFailure_InFlight(t *testing.T) { store := newMockStore[string, int]() cache, err := NewTiered[string, int](store) if err != nil { @@ -1422,13 +1422,13 @@ func TestTieredCache_GetSet_PersistenceLoadFailure_InFlight(t *testing.T) { ctx := context.Background() - // First, do a successful GetSet to prime the flight + // First, do a successful Fetch to prime the flight loader1 := func(ctx context.Context) (int, error) { return 42, nil } - _, err = cache.GetSet(ctx, "key1", loader1) + _, err = cache.Fetch(ctx, "key1", loader1) if err != nil { - t.Fatalf("first GetSet error: %v", err) + t.Fatalf("first Fetch error: %v", err) } // Clear memory to force persistence check @@ -1442,13 +1442,13 @@ func TestTieredCache_GetSet_PersistenceLoadFailure_InFlight(t *testing.T) { } // This tests the persistence load failure path inside the singleflight - _, err = cache.GetSet(ctx, "key1", loader2) + _, err = cache.Fetch(ctx, "key1", loader2) if err == nil { - t.Error("GetSet should return error when persistence load fails in singleflight") + t.Error("Fetch should return error when persistence load fails in singleflight") } } -func TestTieredCache_GetSet_CacheHitAfterFlight(t *testing.T) { +func TestTieredCache_Fetch_CacheHitAfterFlight(t *testing.T) { store := newMockStore[string, int]() cache, err := NewTiered[string, int](store) if err != nil { @@ -1472,12 +1472,12 @@ func TestTieredCache_GetSet_CacheHitAfterFlight(t *testing.T) { go func() { defer wg.Done() - val, err := cache.GetSet(ctx, "key1", loader1) + val, err := cache.Fetch(ctx, "key1", loader1) if err != nil { - t.Errorf("first GetSet error: %v", err) + t.Errorf("first Fetch error: %v", err) } if val != 42 { - t.Errorf("first GetSet value = %d; want 42", val) + t.Errorf("first Fetch value = %d; want 42", val) } }() @@ -1489,12 +1489,12 @@ func TestTieredCache_GetSet_CacheHitAfterFlight(t *testing.T) { atomic.AddInt32(&loaderCalls, 1) return 99, nil // Different value } - val, err := cache.GetSet(ctx, "key1", loader2) + val, err := cache.Fetch(ctx, "key1", loader2) if err != nil { - t.Errorf("second GetSet error: %v", err) + t.Errorf("second Fetch error: %v", err) } if val != 42 { - t.Errorf("second GetSet value = %d; want 42 (from first loader)", val) + t.Errorf("second Fetch value = %d; want 42 (from first loader)", val) } }() @@ -1505,7 +1505,7 @@ func TestTieredCache_GetSet_CacheHitAfterFlight(t *testing.T) { } } -func TestTieredCache_GetSet_FoundInPersistenceDuringSingleflight(t *testing.T) { +func TestTieredCache_Fetch_FoundInPersistenceDuringSingleflight(t *testing.T) { store := newMockStore[string, int]() cache, err := NewTiered[string, int](store) if err != nil { @@ -1526,13 +1526,13 @@ func TestTieredCache_GetSet_FoundInPersistenceDuringSingleflight(t *testing.T) { return 42, nil } - // GetSet should find value in persistence (after initial check), not call loader - val, err := cache.GetSet(ctx, "key1", loader) + // Fetch should find value in persistence (after initial check), not call loader + val, err := cache.Fetch(ctx, "key1", loader) if err != nil { - t.Fatalf("GetSet error: %v", err) + t.Fatalf("Fetch error: %v", err) } if val != 77 { - t.Errorf("GetSet value = %d; want 77 (from persistence)", val) + t.Errorf("Fetch value = %d; want 77 (from persistence)", val) } if loaderCalls != 0 { t.Errorf("loader calls = %d; want 0", loaderCalls) @@ -1693,9 +1693,9 @@ func TestTieredCache_SetAsync_InvalidKey(t *testing.T) { } } -// TestTieredCache_GetSet_MemoryHitDuringSingleflight tests the path where memory +// TestTieredCache_Fetch_MemoryHitDuringSingleflight tests the path where memory // has the value after acquiring singleflight. -func TestTieredCache_GetSet_MemoryHitDuringSingleflight(t *testing.T) { +func TestTieredCache_Fetch_MemoryHitDuringSingleflight(t *testing.T) { store := newMockStore[string, int]() cache, err := NewTiered[string, int](store) if err != nil { @@ -1717,11 +1717,11 @@ func TestTieredCache_GetSet_MemoryHitDuringSingleflight(t *testing.T) { defer done.Done() started.Done() - if _, err := cache.GetSet(ctx, key, func(context.Context) (int, error) { + if _, err := cache.Fetch(ctx, key, func(context.Context) (int, error) { time.Sleep(50 * time.Millisecond) return 1, nil }); err != nil { - t.Errorf("GetSet error: %v", err) + t.Errorf("Fetch error: %v", err) } }() @@ -1746,9 +1746,9 @@ func TestTieredCache_GetSet_MemoryHitDuringSingleflight(t *testing.T) { } } -// TestTieredCache_GetSet_PersistenceHitDuringSingleflight tests the path where +// TestTieredCache_Fetch_PersistenceHitDuringSingleflight tests the path where // persistence has the value during singleflight (second check). -func TestTieredCache_GetSet_PersistenceHitDuringSingleflight(t *testing.T) { +func TestTieredCache_Fetch_PersistenceHitDuringSingleflight(t *testing.T) { store := newMockStore[string, int]() cache, err := NewTiered[string, int](store) if err != nil { @@ -1770,13 +1770,13 @@ func TestTieredCache_GetSet_PersistenceHitDuringSingleflight(t *testing.T) { defer done.Done() started.Done() - val, err := cache.GetSet(ctx, key, func(ctx context.Context) (int, error) { + val, err := cache.Fetch(ctx, key, func(ctx context.Context) (int, error) { // By this time, the second goroutine should have stored to persistence time.Sleep(50 * time.Millisecond) return 1, nil }) if err != nil { - t.Errorf("GetSet error: %v", err) + t.Errorf("Fetch error: %v", err) } // Value could be 99 (from persistence) or 1 (from loader) if val != 99 && val != 1 { @@ -1798,8 +1798,8 @@ func TestTieredCache_GetSet_PersistenceHitDuringSingleflight(t *testing.T) { done.Wait() } -// TestTieredCache_GetSet_SecondCheckMemory tests the second memory check inside singleflight. -func TestTieredCache_GetSet_SecondCheckMemory(t *testing.T) { +// TestTieredCache_Fetch_SecondCheckMemory tests the second memory check inside singleflight. +func TestTieredCache_Fetch_SecondCheckMemory(t *testing.T) { store := newMockStore[string, int]() cache, err := NewTiered[string, int](store) if err != nil { @@ -1826,9 +1826,9 @@ func TestTieredCache_GetSet_SecondCheckMemory(t *testing.T) { wg.Add(1) go func(idx int) { defer wg.Done() - val, err := cache.GetSet(ctx, fmt.Sprintf("key%d", idx), loader) + val, err := cache.Fetch(ctx, fmt.Sprintf("key%d", idx), loader) if err != nil { - t.Errorf("GetSet error: %v", err) + t.Errorf("Fetch error: %v", err) return } results[idx] = val @@ -1845,8 +1845,8 @@ func TestTieredCache_GetSet_SecondCheckMemory(t *testing.T) { } } -// TestTieredCache_GetSet_SecondStoreGetError tests when second store.Get fails. -func TestTieredCache_GetSet_SecondStoreGetError(t *testing.T) { +// TestTieredCache_Fetch_SecondStoreGetError tests when second store.Get fails. +func TestTieredCache_Fetch_SecondStoreGetError(t *testing.T) { store := newSequenceMockStore[string, int]() // First Get returns not found, second Get fails store.failOnGetN = 2 @@ -1859,7 +1859,7 @@ func TestTieredCache_GetSet_SecondStoreGetError(t *testing.T) { ctx := context.Background() - _, err = cache.GetSet(ctx, "key1", func(ctx context.Context) (int, error) { + _, err = cache.Fetch(ctx, "key1", func(ctx context.Context) (int, error) { return 42, nil }) @@ -1869,8 +1869,8 @@ func TestTieredCache_GetSet_SecondStoreGetError(t *testing.T) { } } -// TestTieredCache_GetSet_SecondStoreGetFound tests when second store.Get finds value. -func TestTieredCache_GetSet_SecondStoreGetFound(t *testing.T) { +// TestTieredCache_Fetch_SecondStoreGetFound tests when second store.Get finds value. +func TestTieredCache_Fetch_SecondStoreGetFound(t *testing.T) { store := newSequenceMockStore[string, int]() // First Get returns not found, second Get returns value store.returnOnGetN = 2 @@ -1885,12 +1885,12 @@ func TestTieredCache_GetSet_SecondStoreGetFound(t *testing.T) { ctx := context.Background() loaderCalled := false - val, err := cache.GetSet(ctx, "key1", func(ctx context.Context) (int, error) { + val, err := cache.Fetch(ctx, "key1", func(ctx context.Context) (int, error) { loaderCalled = true return 42, nil }) if err != nil { - t.Fatalf("GetSet failed: %v", err) + t.Fatalf("Fetch failed: %v", err) } // Should return value from second store.Get, not from loader diff --git a/s3fifo.go b/s3fifo.go index e1bf9c6..9889425 100644 --- a/s3fifo.go +++ b/s3fifo.go @@ -69,7 +69,7 @@ const ( minDeathRowSize = 8 ) -// smallSize returns the small queue capacity for a given cache capacity. +// smallRatio returns the optimal small queue ratio (per-mille) for a capacity. // Tuned via binary search across cache sizes (hitrate benchmarks): // // 8K: 148 (14.8%) - small caches need aggressive filtering @@ -80,14 +80,6 @@ const ( // 256K: 152 (15.2%) - large caches need more filtering again // // Uses piecewise linear interpolation between measured points. -func smallSize(capacity int) int { - if capacity <= 0 { - return 0 - } - return capacity * smallRatio(capacity) / 1000 -} - -// smallRatio returns the optimal small queue ratio (per-mille) for a capacity. func smallRatio(capacity int) int { // Tuning points from binary search (capacity -> ratio per-mille). // Interpolate linearly between points. @@ -120,7 +112,7 @@ func smallRatio(capacity int) int { return points[len(points)-1].ratio } -// ghostSize returns the ghost queue capacity for a given cache capacity. +// ghostRatio returns the optimal ghost queue ratio (per-mille) for a capacity. // Tuned via binary search across cache sizes (hitrate benchmarks): // // 8K: 875 ( 88%) - smaller caches need less ghost tracking @@ -132,14 +124,6 @@ func smallRatio(capacity int) int { // // Monotonic increase: larger caches have more unique keys cycling through, // so they need proportionally larger ghost queues to track evictions. -func ghostSize(capacity int) int { - if capacity <= 0 { - return 0 - } - return capacity * ghostRatio(capacity) / 1000 -} - -// ghostRatio returns the optimal ghost queue ratio (per-mille) for a capacity. func ghostRatio(capacity int) int { // Tuning points from binary search (capacity -> ratio per-mille). // Interpolate linearly between points. @@ -295,6 +279,8 @@ func timeToSec(t time.Time) uint32 { // entry is a cached key-value pair with eviction metadata. // Uses seqlock for zero-allocation value storage. +// +//nolint:govet // fieldalignment: generic struct layout varies by type parameters type entry[K comparable, V any] struct { key K value V // stored inline, protected by seqlock @@ -307,10 +293,22 @@ type entry[K comparable, V any] struct { } // storeValue stores a value using seqlock protocol (zero allocations). +// Uses CAS to ensure only one writer can be active at a time, preventing +// sequence corruption when multiple goroutines update the same entry. func (e *entry[K, V]) storeValue(v V) { - seq := e.seq.Add(1) // start write (now odd) - has release semantics - e.value = v - e.seq.Store(seq + 1) // end write (now even) - has release semantics + for { + seq := e.seq.Load() + if seq&1 != 0 { + // Another writer is in progress, spin + continue + } + if e.seq.CompareAndSwap(seq, seq+1) { + // Successfully marked as writing (seq is now odd) + e.value = v + e.seq.Store(seq + 2) // End write (seq is now even) + return + } + } } // loadValue loads a value using seqlock protocol. @@ -437,8 +435,8 @@ func newS3FIFO[K comparable, V any](cfg *config) *s3fifo[K, V] { mu: xsync.NewRBMutex(), entries: xsync.NewMap[K, *entry[K, V]](xsync.WithPresize(size)), capacity: size, - smallThresh: smallSize(size), - ghostCap: ghostSize(size), + smallThresh: size * smallRatio(size) / 1000, + ghostCap: size * ghostRatio(size) / 1000, ghostActive: newBloomFilter(size, ghostFPRate), ghostAging: newBloomFilter(size, ghostFPRate), deathRow: make([]*entry[K, V], deathRowSize), @@ -844,9 +842,7 @@ func (c *s3fifo[K, V]) flush() int { c.ghostActive.Reset() c.ghostAging.Reset() c.ghostFreqRng = ghostFreqRing{} - for i := range c.deathRow { - c.deathRow[i] = nil - } + clear(c.deathRow) c.deathRowPos = 0 c.totalEntries.Store(0) return n diff --git a/s3fifo_test.go b/s3fifo_test.go index 1c3b278..46c87ce 100644 --- a/s3fifo_test.go +++ b/s3fifo_test.go @@ -788,8 +788,8 @@ func TestS3FIFO_GhostQueueSize(t *testing.T) { capacity := 1000 cache := newS3FIFO[int, int](&config{size: capacity}) - // Ghost capacity varies by cache size (see ghostSize function). - want := ghostSize(capacity) + // Ghost capacity varies by cache size (see ghostRatio function). + want := capacity * ghostRatio(capacity) / 1000 if cache.ghostCap != want { t.Errorf("ghost capacity = %d; want %d", cache.ghostCap, want) } @@ -2867,7 +2867,10 @@ func TestSmallSize(t *testing.T) { } for _, tt := range tests { - got := smallSize(tt.capacity) + var got int + if tt.capacity > 0 { + got = tt.capacity * smallRatio(tt.capacity) / 1000 + } if got != tt.want { t.Errorf("smallSize(%d) = %d; want %d", tt.capacity, got, tt.want) } @@ -2951,7 +2954,10 @@ func TestGhostSize(t *testing.T) { } for _, tt := range tests { - got := ghostSize(tt.capacity) + var got int + if tt.capacity > 0 { + got = tt.capacity * ghostRatio(tt.capacity) / 1000 + } if got != tt.want { t.Errorf("ghostSize(%d) = %d; want %d", tt.capacity, got, tt.want) }