review

review patchsets using your default editor
Log | Files | Refs

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 }