zfin/src/commands/server.zig

355 lines
16 KiB
Zig

//! `zfin server refresh SYMBOL...` - ask the shared server to force-refresh
//! symbols in ITS cache, right now.
//!
//! The distinction from `zfin cache refresh` matters and is easy to get backwards:
//!
//! `cache refresh` acts on the LOCAL cache, bypassing the TTL *and* the
//! shared server, so it fetches straight from the provider.
//! `server refresh` asks the SERVER to refresh its own copy. Nothing local
//! changes until the next run syncs from it.
//!
//! Which one you want depends on where the stale bar lives. `zfin diagnose SYMBOL`
//! answers that: when it reports the server offering a bar behind your local copy,
//! or tracking the symbol at all, this is the lever. When the server is fine and
//! only your copy is behind, `cache refresh` is.
//!
//! Exists because a symbol the server serves but does not refresh will hand every
//! client the same stale bar indefinitely, and until now the only fix was an ssh
//! session and a cron run.
const std = @import("std");
const cli = @import("common.zig");
const framework = @import("framework.zig");
const http = @import("../net/http.zig");
pub const ParsedArgs = struct {
symbols: []const []const u8,
};
pub const meta: framework.Meta = .{
.name = "server",
.group = .infra,
.synopsis = "Ask the shared server to refresh its own cache",
.help =
\\Usage: zfin server refresh SYMBOL [SYMBOL...]
\\
\\Force-refreshes candle data in the SHARED SERVER's cache (ZFIN_SERVER),
\\bypassing its TTL. Reports, per symbol, whether its newest bar actually
\\moved - a fetch can succeed and change nothing when the provider has no
\\newer data, which is a finding rather than a success.
\\
\\Not the same as `zfin cache refresh`, which acts on your LOCAL cache and
\\deliberately bypasses the server. Use `zfin diagnose SYMBOL` to see which
\\side is behind before picking one.
\\
\\Nothing local changes: your next normal run picks up the server's new copy.
\\
\\Requires ZFIN_SERVER, and ZFIN_SERVER_API_KEY when the server enforces one.
\\The server caps a single request (currently 25 symbols) and refuses a second
\\concurrent refresh rather than queueing it.
\\
,
.uppercase_first_arg = false,
.user_errors = error{ MissingSubcommand, UnknownSubcommand, MissingSymbol },
};
pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs {
if (cmd_args.len < 1) {
cli.stderrPrint(ctx.io, "Error: 'server' requires a subcommand (refresh)\n");
return error.MissingSubcommand;
}
// Only one subcommand today. Matched explicitly rather than ignored so a typo
// is an error instead of silently refreshing whatever came next - `zfin server
// referesh AMZN` must not treat "referesh" as a symbol.
if (!std.mem.eql(u8, cmd_args[0], "refresh")) {
cli.stderrPrint(ctx.io, "Error: unknown 'server' subcommand (expected: refresh)\n");
return error.UnknownSubcommand;
}
if (cmd_args.len < 2) {
cli.stderrPrint(ctx.io, "Error: 'server refresh' requires at least one symbol\n");
return error.MissingSymbol;
}
return .{ .symbols = cmd_args[1..] };
}
/// One symbol's outcome, as the server reported it.
pub const Outcome = struct {
symbol: []const u8,
ok: bool,
/// Did the newest bar actually advance? The question the caller has: a
/// successful fetch that moves nothing means the provider had nothing newer,
/// which is a different situation from a fix.
moved: bool = false,
last_date: ?[]const u8 = null,
/// Server-side error NAME when `ok` is false, passed through rather than
/// reworded - an auth failure, a rate limit and a delisted ticker each send
/// you somewhere different.
err: ?[]const u8 = null,
};
/// Parse the server's `{"results":[...]}` body.
///
/// Returns null only when the body is not the expected shape at all. Individual
/// fields are read defensively: a newer server adding fields must not cost an
/// older client the ones it understands. Separated from the request so the shape
/// is testable without a server.
pub fn parseResults(arena: std.mem.Allocator, body: []const u8) ?[]const Outcome {
const parsed = std.json.parseFromSlice(std.json.Value, arena, body, .{}) catch return null;
const obj = switch (parsed.value) {
.object => |o| o,
else => return null,
};
const results = switch (obj.get("results") orelse return null) {
.array => |a| a,
else => return null,
};
var out = std.ArrayList(Outcome).empty;
for (results.items) |item| {
const r = switch (item) {
.object => |o| o,
else => continue,
};
const sym = switch (r.get("symbol") orelse continue) {
.string => |s| s,
else => continue,
};
var o = Outcome{ .symbol = sym, .ok = false };
if (r.get("ok")) |v| if (v == .bool) {
o.ok = v.bool;
};
if (r.get("moved")) |v| if (v == .bool) {
o.moved = v.bool;
};
if (r.get("last_date")) |v| if (v == .string) {
o.last_date = v.string;
};
if (r.get("error")) |v| if (v == .string) {
o.err = v.string;
};
out.append(arena, o) catch return null;
}
return out.items;
}
/// Human-readable guidance for a transport-level failure.
///
/// Every branch names the actual condition rather than "request failed": the
/// whole point of `HttpError.Conflict` existing is that "a refresh is already
/// running" and "your key is wrong" are different problems, and collapsing them
/// sends you to the wrong place.
pub fn failureAdvice(err: anyerror) []const u8 {
return switch (err) {
error.Conflict => "a refresh is already running there - retry once it finishes",
error.Unauthorized => "rejected: check ZFIN_SERVER_API_KEY",
error.NotFound => "this server has no /refresh endpoint - it predates the feature",
error.RateLimited => "the server is rate-limiting; retry shortly",
error.ServerError => "the server errored; check its logs",
// 400 lands here, and the server's 400s are specific ("Too many symbols:
// 26 (limit 25)", "Invalid symbol: NK E"). That text is already emitted by
// the transport as an `http rejection body` warning, so point at it rather
// than reprinting "request failed" directly beneath the real reason.
//
// Deliberately NOT solved by mapping 400 to its own HttpError variant:
// `InvalidResponse` is load-bearing elsewhere - `service.zig` routes it to
// the Yahoo fallback and `isPermanentProviderFailure` counts it transient -
// so renaming it would alter the provider chain to improve one CLI message.
error.InvalidResponse => "the server rejected the request - its reason is on the `http rejection body` line above",
else => "request failed",
};
}
pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const io = ctx.io;
const out = ctx.out;
const base = ctx.config.server_url orelse {
cli.stderrPrint(io, "Error: ZFIN_SERVER is not set - there is no server to ask\n");
return;
};
var arena_state = std.heap.ArenaAllocator.init(ctx.allocator);
defer arena_state.deinit();
const arena = arena_state.allocator();
// Uppercased here so the request matches what the server stores and what the
// response echoes; the framework's `uppercase_first_arg` only covers the first
// operand, which for this command is the subcommand.
var joined = std.ArrayList(u8).empty;
for (parsed.symbols, 0..) |sym, i| {
if (i > 0) try joined.append(arena, ',');
const upper = try arena.alloc(u8, sym.len);
for (sym, 0..) |c, j| upper[j] = std.ascii.toUpper(c);
try joined.appendSlice(arena, upper);
}
// Percent-encoded rather than interpolated. A hand-built query string put a
// malformed symbol's space straight into the request line, so httpz rejected
// the request at the HTTP layer and answered with its own generic "Invalid
// Request" - the operator saw a protocol complaint instead of the server's
// "Invalid symbol: NK E". An `&` would have been worse: it would silently
// truncate the symbol list. `isQueryValueChar` leaves `,` unencoded, which is
// what the server splits on.
const endpoint = try std.fmt.allocPrint(arena, "{s}/refresh", .{base});
const url = try http.buildUrl(arena, endpoint, &.{.{ "symbols", joined.items }});
var client = http.Client.init(io, arena);
defer client.deinit();
var hdr: [1]std.http.Header = .{.{ .name = "", .value = "" }};
const extra: []const std.http.Header = if (ctx.config.server_api_key) |k| blk: {
hdr[0] = .{ .name = "X-API-Key", .value = k };
break :blk hdr[0..1];
} else &.{};
try out.print("Asking {s} to refresh {d} symbol(s)...\n", .{ base, parsed.symbols.len });
try out.flush();
// POST, with an empty body: the symbols travel as query parameters, matching
// the server's other operator endpoints and keeping the call curl-able.
var resp = client.request(.POST, url, "", extra) catch |err| {
// Both the advice and the underlying error name: the advice is what to do,
// the name is what actually happened, and dropping either leaves the
// operator guessing at one of them.
const msg = std.fmt.allocPrint(arena, "Error: {s} ({s})\n", .{
failureAdvice(err),
@errorName(err),
}) catch "Error: request failed\n";
cli.stderrPrint(io, msg);
return;
};
defer resp.deinit();
const results = parseResults(arena, resp.body) orelse {
cli.stderrPrint(io, "Error: could not parse the server's reply\n");
return;
};
var moved: usize = 0;
var failed: usize = 0;
for (results) |r| {
if (!r.ok) {
failed += 1;
try out.print(" {s:<10} FAILED {s}\n", .{ r.symbol, r.err orelse "unknown error" });
continue;
}
if (r.moved) moved += 1;
// "unchanged" rather than "ok": a successful fetch that moved nothing is
// the shape of a provider with no newer data, and calling it ok would hide
// exactly what the operator came to find out.
try out.print(" {s:<10} {s:<10} {s}\n", .{
r.symbol,
if (r.moved) "moved" else "unchanged",
r.last_date orelse "no bar",
});
}
try out.print("\n{d} moved, {d} unchanged, {d} failed\n", .{
moved,
results.len - moved - failed,
failed,
});
if (moved > 0) {
try out.print("Your local cache is untouched - a normal run will sync the new copy.\n", .{});
}
try out.flush();
}
// ── tests ────────────────────────────────────────────────────
const testing = std.testing;
test "parseResults: the shape zfin-server actually returns" {
// Verbatim from `POST /refresh?symbols=NKE,AMZN,ZZZZQQ` against a live server.
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const body =
\\{"results":[{"symbol":"NKE","ok":true,"moved":true,"last_date":"2026-08-12"},{"symbol":"AMZN","ok":true,"moved":false,"last_date":"2026-08-12"},{"symbol":"ZZZZQQ","ok":false,"error":"FetchFailed"}]}
;
const r = parseResults(arena.allocator(), body).?;
try testing.expectEqual(@as(usize, 3), r.len);
try testing.expectEqualStrings("NKE", r[0].symbol);
try testing.expect(r[0].ok);
try testing.expect(r[0].moved);
try testing.expectEqualStrings("2026-08-12", r[0].last_date.?);
// The distinction the command exists to surface: fetched fine, moved nothing.
try testing.expect(r[1].ok);
try testing.expect(!r[1].moved);
// A failure carries the server's error name, not a reworded summary.
try testing.expect(!r[2].ok);
try testing.expectEqualStrings("FetchFailed", r[2].err.?);
}
test "parseResults: a null last_date survives, and junk is rejected" {
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const a = arena.allocator();
// An uncached symbol reports success with no bar. `null` must not become the
// string "null" or an empty date.
const r = parseResults(a, "{\"results\":[{\"symbol\":\"X\",\"ok\":true,\"moved\":false,\"last_date\":null}]}").?;
try testing.expectEqual(@as(?[]const u8, null), r[0].last_date);
// Not JSON at all - e.g. an HTML error page from a proxy in front of it.
try testing.expectEqual(@as(?[]const Outcome, null), parseResults(a, "<html>502</html>"));
// JSON, wrong shape.
try testing.expectEqual(@as(?[]const Outcome, null), parseResults(a, "[1,2,3]"));
try testing.expectEqual(@as(?[]const Outcome, null), parseResults(a, "{\"other\":[]}"));
// An empty result set is valid, not an error.
try testing.expectEqual(@as(usize, 0), parseResults(a, "{\"results\":[]}").?.len);
}
test "parseResults: an unknown field does not blind an older client" {
// Forward compatibility, the same reason `diagnose` reads fields individually
// rather than by struct coercion.
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const body =
\\{"results":[{"symbol":"NKE","ok":true,"moved":true,"last_date":"2026-08-12","queued_at":123,"extra":{"a":1}}],"summary":"whatever"}
;
const r = parseResults(arena.allocator(), body).?;
try testing.expectEqual(@as(usize, 1), r.len);
try testing.expect(r[0].moved);
}
test "failureAdvice: each condition points somewhere different" {
// The justification for adding `HttpError.Conflict` at all. Before it, 409
// collapsed into InvalidResponse and this command would have told the operator
// their request was malformed when it just needed a retry.
try testing.expect(std.mem.indexOf(u8, failureAdvice(error.Conflict), "already running") != null);
try testing.expect(std.mem.indexOf(u8, failureAdvice(error.Unauthorized), "ZFIN_SERVER_API_KEY") != null);
try testing.expect(std.mem.indexOf(u8, failureAdvice(error.NotFound), "predates") != null);
// 400 arrives as InvalidResponse. The advice must send the operator to the
// logged rejection body, which carries the server's exact complaint - observed
// returning "Too many symbols: 26 (limit 25)" while this line said only
// "request failed".
try testing.expect(std.mem.indexOf(u8, failureAdvice(error.InvalidResponse), "rejection body") != null);
try testing.expect(!std.mem.eql(u8, failureAdvice(error.InvalidResponse), failureAdvice(error.ConnectionRefused)));
// Every branch must say something concrete; none may be empty.
for ([_]anyerror{
error.Conflict, error.Unauthorized, error.NotFound,
error.RateLimited, error.ServerError, error.InvalidResponse,
error.ConnectionRefused,
}) |e| {
try testing.expect(failureAdvice(e).len > 0);
}
// And the advice must never be the same for Conflict and Unauthorized, which
// is the exact collapse this replaced.
try testing.expect(!std.mem.eql(u8, failureAdvice(error.Conflict), failureAdvice(error.Unauthorized)));
}
test "the symbols parameter is percent-encoded" {
// Guards the bug this shipped with: an unencoded space made the request line
// malformed, so the server never saw the symbol and could not explain what was
// wrong with it. An `&` would have truncated the list without any error at all.
const a = testing.allocator;
const url = try http.buildUrl(a, "https://h/refresh", &.{.{ "symbols", "NK E,A&B" }});
defer a.free(url);
try testing.expectEqualStrings("https://h/refresh?symbols=NK%20E,A%26B", url);
// The comma must NOT be encoded - it is the separator the server splits on.
try testing.expect(std.mem.indexOfScalar(u8, url, ',') != null);
}