cache.odin (6128B)
1 /* 2 Package cache remembers what a provider answered, so that a re-run of a 3 review whose parts did not change does not ask again. A loop iterates by 4 re-running: one fix, one more reading. Without the cache every re-run 5 re-asks every job, at full price and with a fresh roll of the dice — the 6 same question answered two ways is the flicker that makes a loop chase 7 ghosts. With it, a job whose rendered subject is byte for byte the same 8 replays the recorded answer, which is both free and stable. 9 */ 10 package cache 11 12 import "core:crypto/sha2" 13 import "core:encoding/hex" 14 import "core:encoding/json" 15 import "core:fmt" 16 import "core:os" 17 import "core:path/filepath" 18 import "core:slice" 19 import "core:strings" 20 import "core:sync" 21 import "core:time" 22 23 import "../provider" 24 25 // version is part of every key, so that a change to what an ask holds 26 // invalidates the recorded answers instead of replaying stale ones. 27 version :: "review-answers-v1" 28 29 // bound is the entry count past which the oldest answers are dropped. 30 bound :: 4000 31 32 // Entry is one recorded answer and what it cost when it was asked. The 33 // time is written as the Go tool writes it, RFC 3339 with the local 34 // offset, so that either tool reads the other's file. 35 Entry :: struct { 36 text: string `json:"text"`, 37 tokens_in: int `json:"in"`, 38 tokens_out: int `json:"out"`, 39 cached: int `json:"cached"`, 40 cost: f64 `json:"cost"`, 41 at: string `json:"at"`, 42 } 43 44 // Cache is one file of recorded answers, keyed by the exact question. 45 // fresh skips the reads but keeps the writes, so --fresh re-asks 46 // everything and leaves the answers behind it. A file that was there and 47 // could not be read is kept out of harm's way: nothing is written over 48 // it, since a save would replace every answer it holds with this run's 49 // few. 50 Cache :: struct { 51 path: string, 52 fresh: bool, 53 entries: map[string]Entry, 54 dirty: bool, 55 hits: int, 56 protected: bool, 57 lock: sync.Mutex, 58 } 59 60 // open reads the answer file from the user's cache directory, or the path 61 // given. A cache that cannot be read is no fault of the review: the run 62 // asks the provider as it would have. A corrupt file is answered with 63 // nothing; the next save rewrites it. 64 open :: proc(fresh: bool, path := "", allocator := context.allocator) -> (c: ^Cache, ok: bool) { 65 location := path 66 if location == "" { 67 dir, err := os.user_cache_dir(context.temp_allocator) 68 if err != nil { 69 return nil, false 70 } 71 location = filepath.join({dir, "review", "answers.json"}, allocator) or_else "" 72 } else { 73 location = strings.clone(path, allocator) 74 } 75 c = new(Cache, allocator) 76 c.path = location 77 c.fresh = fresh 78 c.entries = make(map[string]Entry, allocator) 79 data, err := os.read_entire_file_from_path(location, context.temp_allocator) 80 if err != nil { 81 return c, true // No cache yet, which is not an error. 82 } 83 loaded: map[string]Entry 84 if json.unmarshal(data, &loaded, allocator = allocator) == nil { 85 c.entries = loaded 86 } else if len(data) > 0 { 87 c.protected = true 88 } 89 return c, true 90 } 91 92 // key is the question, hashed with the provider that was asked and the 93 // cache's version. 94 key :: proc(provider_name, system, user: string, allocator := context.allocator) -> string { 95 joined := strings.join({version, provider_name, system, user}, "\x00", context.temp_allocator) 96 ctx: sha2.Context_256 97 sha2.init_256(&ctx) 98 sha2.update(&ctx, transmute([]byte)joined) 99 digest: [32]byte 100 sha2.final(&ctx, digest[:]) 101 return string(hex.encode(digest[:], allocator)) 102 } 103 104 // get returns the answer recorded for exactly this question, marked as a 105 // replay: the run asked nothing, so the replay's usage counts stay at 106 // zero. 107 get :: proc( 108 c: ^Cache, 109 provider_name, system, user: string, 110 allocator := context.allocator, 111 ) -> ( 112 provider.Answer, 113 bool, 114 ) { 115 if c == nil { 116 return {}, false 117 } 118 sync.mutex_lock(&c.lock) 119 defer sync.mutex_unlock(&c.lock) 120 if c.fresh { 121 return {}, false 122 } 123 entry, found := c.entries[key(provider_name, system, user, context.temp_allocator)] 124 if !found { 125 return {}, false 126 } 127 c.hits += 1 128 return provider.Answer{text = strings.clone(entry.text, allocator), replayed = true}, true 129 } 130 131 // put records an answer under its question. 132 put :: proc(c: ^Cache, provider_name, system, user: string, answer: provider.Answer) { 133 if c == nil { 134 return 135 } 136 sync.mutex_lock(&c.lock) 137 defer sync.mutex_unlock(&c.lock) 138 c.entries[key(provider_name, system, user)] = Entry { 139 text = strings.clone(answer.text), 140 tokens_in = answer.tokens_in, 141 tokens_out = answer.tokens_out, 142 cached = answer.cached, 143 cost = answer.cost, 144 at = stamp(), 145 } 146 c.dirty = true 147 } 148 149 // stamp is the present, as RFC 3339 in UTC, which sorts as it ages and 150 // which the Go tool's reader accepts. 151 stamp :: proc(allocator := context.allocator) -> string { 152 now := time.now() 153 y, mo, d := time.date(now) 154 h, mi, sec := time.clock_from_time(now) 155 return fmt.aprintf( 156 "%04d-%02d-%02dT%02d:%02d:%02dZ", 157 y, 158 int(mo), 159 d, 160 h, 161 mi, 162 sec, 163 allocator = allocator, 164 ) 165 } 166 167 // save writes the file back when this run recorded anything, dropping the 168 // oldest entries past the bound. A failed write stays silent: the cache 169 // is a saving, not a result. 170 save :: proc(c: ^Cache) { 171 if c == nil { 172 return 173 } 174 sync.mutex_lock(&c.lock) 175 defer sync.mutex_unlock(&c.lock) 176 if !c.dirty || c.protected { 177 return 178 } 179 if len(c.entries) > bound { 180 Aged :: struct { 181 key: string, 182 at: string, 183 } 184 ages := make([dynamic]Aged, context.temp_allocator) 185 for k, e in c.entries { 186 append(&ages, Aged{k, e.at}) 187 } 188 slice.sort_by_cmp(ages[:], proc(a, b: Aged) -> slice.Ordering { 189 return .Less if a.at < b.at else (.Greater if a.at > b.at else .Equal) 190 }) 191 for a in ages[:len(ages) - bound] { 192 delete_key(&c.entries, a.key) 193 } 194 } 195 data, err := json.marshal(c.entries, allocator = context.temp_allocator) 196 if err != nil { 197 return 198 } 199 if os.make_directory_all(filepath.dir(c.path)) != nil { 200 return 201 } 202 temp := strings.concatenate({c.path, ".tmp"}, context.temp_allocator) 203 if os.write_entire_file(temp, data) != nil { 204 return 205 } 206 os.rename(temp, c.path) 207 c.dirty = false 208 }