implement a server cache sync

This commit is contained in:
Emil Lerch 2026-03-11 13:01:37 -07:00
parent 2f49a99ca4
commit ab76693367
Signed by: lobo
GPG key ID: A7B62D657EF764F8
3 changed files with 77 additions and 1 deletions

4
src/cache/store.zig vendored
View file

@ -374,7 +374,9 @@ pub const Store = struct {
};
}
fn writeRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void {
/// Write raw bytes to a cache file. Used by server sync to write
/// pre-serialized SRF data directly to the cache.
pub fn writeRaw(self: *Store, symbol: []const u8, data_type: DataType, data: []const u8) !void {
try self.ensureSymbolDir(symbol);
const path = try self.symbolPath(symbol, data_type.fileName());
defer self.allocator.free(path);

View file

@ -8,6 +8,8 @@ pub const Config = struct {
finnhub_key: ?[]const u8 = null,
alphavantage_key: ?[]const u8 = null,
openfigi_key: ?[]const u8 = null,
/// URL of a zfin-server instance for lazy cache sync (e.g. "https://zfin.lerch.org")
server_url: ?[]const u8 = null,
cache_dir: []const u8,
allocator: ?std.mem.Allocator = null,
/// Raw .env file contents (keys/values in env_map point into this).
@ -32,6 +34,7 @@ pub const Config = struct {
self.finnhub_key = self.resolve("FINNHUB_API_KEY");
self.alphavantage_key = self.resolve("ALPHAVANTAGE_API_KEY");
self.openfigi_key = self.resolve("OPENFIGI_API_KEY");
self.server_url = self.resolve("ZFIN_SERVER");
const env_cache = self.resolve("ZFIN_CACHE_DIR");
self.cache_dir = env_cache orelse blk: {

View file

@ -28,6 +28,7 @@ const alphavantage = @import("providers/alphavantage.zig");
const OpenFigi = @import("providers/openfigi.zig");
const fmt = @import("format.zig");
const performance = @import("analytics/performance.zig");
const http = @import("net/http.zig");
pub const DataError = error{
NoApiKey,
@ -164,6 +165,12 @@ pub const DataService = struct {
if (s.read(T, symbol, postProcess, .fresh_only)) |cached|
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
// Try server sync before hitting providers
if (self.syncFromServer(symbol, data_type)) {
if (s.read(T, symbol, postProcess, .fresh_only)) |cached|
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
}
const fetched = self.fetchFromProvider(T, symbol) catch |err| {
if (err == error.RateLimited) {
// Wait and retry once
@ -232,6 +239,15 @@ pub const DataService = struct {
return .{ .data = r.data, .source = .cached, .timestamp = mr.created };
}
// Stale try server sync before incremental fetch
if (self.syncCandlesFromServer(symbol)) {
if (s.isCandleMetaFresh(symbol)) {
if (s.read(Candle, symbol, null, .any)) |r|
return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() };
}
// Server data also stale fall through to incremental fetch
}
// Stale try incremental update using last_date from meta
const fetch_from = m.last_date.addDays(1);
@ -283,6 +299,15 @@ pub const DataService = struct {
}
}
// No usable cache try server sync first
if (self.syncCandlesFromServer(symbol)) {
if (s.isCandleMetaFresh(symbol)) {
if (s.read(Candle, symbol, null, .any)) |r|
return .{ .data = r.data, .source = .cached, .timestamp = std.time.timestamp() };
}
// Server data also stale fall through to full fetch
}
// No usable cache full fetch (~10 years, plus buffer for leap years)
var td = try self.getProvider(TwelveData);
const from = today.addDays(-3700);
@ -347,6 +372,12 @@ pub const DataService = struct {
self.allocator.free(cached.data);
}
// Try server sync before hitting Finnhub
if (self.syncFromServer(symbol, .earnings)) {
if (s.read(EarningsEvent, symbol, earningsPostProcess, .fresh_only)) |cached|
return .{ .data = cached.data, .source = .cached, .timestamp = cached.timestamp };
}
var fh = try self.getProvider(Finnhub);
const from = today.subtractYears(5);
const to = today.addDays(365);
@ -721,6 +752,46 @@ pub const DataService = struct {
}
}
// Server sync
/// Try to sync a cache file from the configured zfin-server.
/// Returns true if the file was successfully synced, false on any error.
/// Silently returns false if no server is configured.
fn syncFromServer(self: *DataService, symbol: []const u8, data_type: cache.DataType) bool {
const server_url = self.config.server_url orelse return false;
const endpoint = switch (data_type) {
.candles_daily => "/candles",
.candles_meta => "/candles_meta",
.dividends => "/dividends",
.earnings => "/earnings",
.options => "/options",
.splits => return false, // not served
.etf_profile => return false, // not served
.meta => return false,
};
const full_url = std.fmt.allocPrint(self.allocator, "{s}/{s}{s}", .{ server_url, symbol, endpoint }) catch return false;
defer self.allocator.free(full_url);
var client = http.Client.init(self.allocator);
defer client.deinit();
var response = client.get(full_url) catch return false;
defer response.deinit();
// Write to local cache
var s = self.store();
s.writeRaw(symbol, data_type, response.body) catch return false;
return true;
}
/// Sync candle data (both daily and meta) from the server.
fn syncCandlesFromServer(self: *DataService, symbol: []const u8) bool {
const daily = self.syncFromServer(symbol, .candles_daily);
const meta = self.syncFromServer(symbol, .candles_meta);
return daily and meta;
}
/// Mutual funds use 5-letter tickers ending in X (e.g. FDSCX, VSTCX, FAGIX).
/// These don't have quarterly earnings on Finnhub.
fn isMutualFund(symbol: []const u8) bool {