cacheKeys function in cache.zig

This commit is contained in:
Emil Lerch 2026-08-10 16:35:29 -07:00
parent cd053b9a78
commit 2da30b2474
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 140 additions and 30 deletions

View file

@ -316,6 +316,11 @@ across the codebase so search-and-replace stays trivial):
*5678`
- Filenames: `Sample_IRA_1234.txt`, `Sample_IRA_5678.txt`,
`smpl_1234`, `smpl-ira-1234`
- Holder names: **`Riley` is an approved placeholder holder name** and
appears in existing fixtures (e.g. `<institution> Riley 401(k)`). It is
NOT a real family member. Do not "scrub" it, and do not ask whether it
needs scrubbing - it is already the placeholder. Reuse it rather than
inventing new person names.
- Account numbers: `1234`, `5678`, `9012`, `3456`, `7890`, or
alphanumeric like `Z123`, `Z111`, `Z222`. Do not use real
trailing-digit values from the user's actual accounts file.
@ -386,6 +391,13 @@ Two known classes:
approved placeholder vocabulary above - e.g. the real name
`Inherited IRA` is a substring of the sanctioned fixture value
`Sample Inherited IRA`.
- **Institution names and the placeholder holder name.** Broker brands
(Fidelity, Schwab, Vanguard, ...) appear throughout source legitimately -
in parser names, money-market symbol lists, and doc comments - and are
not PII. Because real account names combine an institution with a
holder, a token list built from `accounts.srf` will match all of those.
`Riley` likewise: it is the approved placeholder holder name, so
`<institution> Riley 401(k)` in a fixture is already scrubbed.
So the rule is: **inspect the context of every hit**, and confirm each
is either coincidental or a generic category before dismissing it. A

120
src/cache/store.zig vendored
View file

@ -321,8 +321,66 @@ pub const Store = struct {
return stats;
}
// Generic typed API
/// Every cache key on disk, sorted. Caller owns the strings and the outer
/// slice.
///
/// Deliberately dumb: it lists directory names and makes no judgement about
/// what a key means. A key may be a ticker, a CUSIP, or an EDGAR CIK - this
/// is a generic SRF store and classifying them is not its business.
///
/// Callers filter by what they actually need, which is usually cheaper and
/// always more accurate than guessing from the name. A candle-staleness
/// sweep, for instance, wants keys that have candle meta, so
/// `readCandleMeta() != null` is both its filter and its data - and it
/// excludes CIK keys, negative-cached symbols and the EDGAR indexes for
/// free, without this function knowing any of those exist.
///
/// The one exclusion is the store's own `_`-prefixed synthetic keys
/// (`_edgar` for the EDGAR ticker indexes, `_torn` for archived torn-body
/// forensics). That is self-knowledge, not domain knowledge: this store
/// created them.
///
/// Read-only. Returns empty (not an error) when the cache directory does
/// not exist yet.
pub fn cacheKeys(self: *Store, allocator: std.mem.Allocator) ![][]const u8 {
const io = self.io;
var out = std.ArrayList([]const u8).empty;
errdefer {
for (out.items) |k| allocator.free(k);
out.deinit(allocator);
}
var dir = std.Io.Dir.cwd().openDir(io, self.cache_dir, .{ .iterate = true }) catch
return out.toOwnedSlice(allocator);
defer dir.close(io);
var iter = dir.iterate();
while (iter.next(io) catch null) |entry| {
if (entry.kind != .directory) continue;
if (entry.name.len == 0 or entry.name[0] == '_') continue;
const owned = try allocator.dupe(u8, entry.name);
{
errdefer allocator.free(owned);
try out.append(allocator, owned);
}
}
const keys = try out.toOwnedSlice(allocator);
std.mem.sort([]const u8, keys, {}, struct {
fn lt(_: void, a: []const u8, b: []const u8) bool {
return std.mem.order(u8, a, b) == .lt;
}
}.lt);
return keys;
}
/// Free a `cacheKeys` result.
pub fn freeCacheKeys(allocator: std.mem.Allocator, keys: [][]const u8) void {
for (keys) |k| allocator.free(k);
allocator.free(keys);
}
// Generic typed API
/// Map a model type to its cache DataType.
pub fn dataTypeFor(comptime T: type) DataType {
return switch (T) {
@ -3761,3 +3819,63 @@ test "deserializePortfolio: underscore digit separators parse (they always did)"
try std.testing.expectEqual(@as(usize, 1), p.lots.len);
try std.testing.expectApproxEqAbs(@as(f64, 1_234_567), p.lots[0].shares, 0.5);
}
test "cacheKeys: directory names only, sorted, store-internal keys excluded" {
const allocator = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
defer allocator.free(dir_path);
// Unsorted on disk, salted with the shapes a real cache holds. Note
// `0000320193` (a CIK) IS returned - classifying keys is the caller's job,
// and a candle sweep drops it for free because it has no candle meta.
for ([_][]const u8{ "NVDA", "AAPL", "_edgar", "_torn", "0000320193", "BRK-B" }) |name|
try tmp.dir.createDir(io, name, std.Io.File.Permissions.default_dir);
(try tmp.dir.createFile(io, "cusip_tickers.srf", .{})).close(io);
var s = Store.init(io, allocator, dir_path);
const keys = try s.cacheKeys(allocator);
defer Store.freeCacheKeys(allocator, keys);
try std.testing.expectEqual(@as(usize, 4), keys.len);
try std.testing.expectEqualStrings("0000320193", keys[0]);
try std.testing.expectEqualStrings("AAPL", keys[1]);
try std.testing.expectEqualStrings("BRK-B", keys[2]);
try std.testing.expectEqualStrings("NVDA", keys[3]);
}
test "cacheKeys: a missing cache directory is empty, not an error" {
const allocator = std.testing.allocator;
const io = std.testing.io;
// First run on a fresh machine: callers must report "nothing cached"
// rather than failing.
var s = Store.init(io, allocator, "/nonexistent/zfin-cache-path");
const keys = try s.cacheKeys(allocator);
defer Store.freeCacheKeys(allocator, keys);
try std.testing.expectEqual(@as(usize, 0), keys.len);
}
/// OOM-path wrapper for `checkAllAllocationFailures`.
fn cacheKeysOom(a: std.mem.Allocator, s: *Store) !void {
const keys = try s.cacheKeys(a);
Store.freeCacheKeys(a, keys);
}
test "cacheKeys: every allocation-failure path unwinds cleanly" {
// The same check on `Portfolio.fetchedSymbols` found a real double-free in
// this exact shape of code - an inner errdefer left armed past the point
// the list took ownership.
const allocator = std.testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const dir_path = try tmp.dir.realPathFileAlloc(io, ".", allocator);
defer allocator.free(dir_path);
for ([_][]const u8{ "AAPL", "NVDA", "MSFT" }) |name|
try tmp.dir.createDir(io, name, std.Io.File.Permissions.default_dir);
var s = Store.init(io, allocator, dir_path);
try std.testing.checkAllAllocationFailures(allocator, cacheKeysOom, .{&s});
}

View file

@ -95,46 +95,26 @@ fn runStats(ctx: *framework.RunCtx) !void {
const now_s = std.Io.Timestamp.now(io, .real).toSeconds();
try out.print("Cache directory: {s}\n\n", .{config.cache_dir});
var dir = std.Io.Dir.cwd().openDir(io, config.cache_dir, .{ .iterate = true }) catch {
// Enumerate via the store rather than walking the directory here. The
// staleness sweep needs the same list, and two hand-rolled walks would
// drift the way the watch-symbol unions did.
var store = Store.init(io, allocator, config.cache_dir);
const symbols = store.cacheKeys(allocator) catch {
try out.print(" (empty -- no cached data)\n", .{});
return;
};
defer dir.close(io);
defer Store.freeCacheKeys(allocator, symbols);
// Collect and sort symbol names
var symbols: std.ArrayList([]const u8) = .empty;
defer {
for (symbols.items) |s| allocator.free(s);
symbols.deinit(allocator);
}
var iter = dir.iterate();
while (iter.next(io) catch null) |entry| {
if (entry.kind == .directory) {
const name = allocator.dupe(u8, entry.name) catch continue;
symbols.append(allocator, name) catch {
allocator.free(name);
continue;
};
}
}
if (symbols.items.len == 0) {
if (symbols.len == 0) {
try out.print(" (empty -- no cached data)\n", .{});
return;
}
std.mem.sort([]const u8, symbols.items, {}, struct {
fn cmp(_: void, a: []const u8, b: []const u8) bool {
return std.mem.order(u8, a, b) == .lt;
}
}.cmp);
// Track totals
var total_size: u64 = 0;
var total_files: usize = 0;
for (symbols.items) |symbol| {
for (symbols) |symbol| {
try out.print("{s}\n", .{symbol});
// Print header
@ -205,7 +185,7 @@ fn runStats(ctx: *framework.RunCtx) !void {
var total_buf: [10]u8 = undefined;
try out.print("{d} symbol(s), {d} file(s), {s} total\n", .{
symbols.items.len,
symbols.len,
total_files,
formatSize(&total_buf, total_size),
});