refactor: Eliminated global TZ cache

The configured timezone (or local) is now set on Site directly.
This commit is contained in:
Spencer Brower
2026-07-24 11:32:47 -04:00
parent 4f09312daf
commit 75a7d7a14b
9 changed files with 114 additions and 144 deletions
+5 -2
View File
@@ -222,11 +222,14 @@ Data is passed as **typed structs** (not `map[string]any`). Mustache resolves st
```odin
Base_Data :: struct {
now: datetime.DateTime,
now: string, // UTC ISO 8601 build timestamp
params: json.Value,
body: string,
title: string,
description: string,
og: Open_Graph,
date_format: string, // from site.date.format (thor.json)
timezone: ^datetime.TZ_Region, // loaded from site.date.timezone or local, owned by Site
}
Page_Data :: struct {
using base: Base_Data, // fields promoted via reflection fallback
@@ -259,7 +262,7 @@ Section tags and interpolation tags may transform the resolved value before rend
<time datetime="{{date}}">{{date | format}}</time>
```
Currently implemented: `group_by <field>` (list → list-of-groups) and `format` (ISO date string → display string like "15 Mar 2026"). Filter results live in `context.temp_allocator` (render-scoped). See `mustache/EXTENSIONS.md` for syntax details, caps (`MAX_PIPES`, `MAX_PIPE_ARGS`), and the `Group` struct shape.
Currently implemented: `group_by <field>` (list → list-of-groups) and `format` (ISO date string → display string like "15 Mar 2026"). The `format` pipe resolves `date_format` (string) and `timezone` (`^datetime.TZ_Region`) from the data context. When `timezone` is non-nil, dates are DST-aware converted before formatting. The `MST` token reflects the active timezone abbreviation (e.g. `"EST"`/`"EDT"`) or the source offset (e.g. `"UTC-04:00"`) when no target tz is configured. TZ data is loaded once by `init_site` via `timezone.region_load` using the site arena allocator, stored on `Site.tz`, and freed when the arena is destroyed. Filter results live in `context.temp_allocator` (render-scoped). See `mustache/EXTENSIONS.md` for syntax details, caps (`MAX_PIPES`, `MAX_PIPE_ARGS`), and the `Group` struct shape.
### Comments
+3 -1
View File
@@ -33,11 +33,13 @@
- [x] Accept "strings"
- [x] Accept keys
- [x] handle timezones
- [ ] Fix TZ cache leak in parallel tests: `tz_cache` uses `context.allocator` (tracking allocator in tests), but it's global state shared across parallel test threads. Race causes leak warnings. Fix: use heap allocator for TZ cache.
- [ ] display an error when no part of the date appears in the output.
- [x] use `date.format` as the default format.
- [ ] Handle 0 and whitespace padding i.e. "_2" -> " 2"
- [ ] Do we *need* mustache.Date_Components, or can we use core:time/datetime.DateTime?
- [ ] show a proper diagnostic for timezones
- currently "unable to load timezone 'America/New_Yorkskie'"
- want rust style diagnostic and better message, maybe "unknown timezone 'America/New_Yorkskie'"
## General
- [ ] Integrity hash
+20
View File
@@ -67,6 +67,26 @@ Takes an optional arg for the Go reference-date layout to use:
- A bare key, resolved from context like any other field: `{{date | format long}}` uses the value of `long` (e.g. a site-config field) as the layout.
- No arg: falls back to the `date_format` context key (typically `date.format` from `thor.json`).
#### Timezone conversion
When the `timezone` context key is set (typically `date.timezone` from `thor.json`, an IANA name like `"America/New_York"`), the `format` pipe converts dates to that timezone before formatting:
- **Date has offset + target tz**: adjusts to true UTC, then converts to the target timezone (DST-aware).
- **Date has no offset + target tz**: assumes the date is already in the target timezone — no conversion, only resolves the abbreviation.
- **Date has offset + no target tz**: displays in the source offset.
- **Date has no offset + no target tz**: displays as-is, assumes UTC.
The `MST` token reflects the active timezone:
| Config tz | ISO has offset | `MST` output |
|---|---|---|
| `"America/New_York"` | yes | `"EST"` or `"EDT"` (DST-aware) |
| `"America/New_York"` | no | `"EST"` or `"EDT"` |
| not set | yes | `"UTC-04:00"` (from source offset) |
| not set | no | `"UTC"` |
Timezone data is loaded once by `init_site` via `core:time/timezone.region_load`, using the site arena allocator. The resolved `^datetime.TZ_Region` pointer is stored on `Site.tz` and passed to templates through `Base_Data.timezone`. The arena frees it automatically on `destroy_site`.
### Memory ownership
- **Parsed pipe filters** (`Pipe_Filter` values) are stored inline on each `Node` via `[dynamic; MAX_PIPES]Pipe_Filter`, and `args` is inline on each `Pipe_Filter` via `[dynamic; MAX_PIPE_ARGS]string`. Both use Odin's fixed-capacity dynamic array type, so no per-tag heap allocations occur at parse time. The storage dies with the `Template` when `delete_template` is called.
+21 -24
View File
@@ -240,33 +240,30 @@ format_offset :: proc(offset_seconds: int) -> string {
return fmt.tprintf("UTC%s%02d:%02d", sign, hours, minutes)
}
// tz_cache caches loaded TZ_Region pointers by timezone name.
// Entries persist until destroy_tz_cache is called.
tz_cache: map[string]^datetime.TZ_Region
get_cached_tz :: proc(name: string) -> (^datetime.TZ_Region, bool) {
if name == "" || name == "UTC" {
return nil, true
// resolve_tz looks up the `tz` field from the context stack and returns
// the ^TZ_Region pointer, or nil if not set.
resolve_tz :: proc(ctx: []any) -> ^datetime.TZ_Region {
raw := resolve_name("timezone", ctx)
if raw == nil do return nil
switch v in raw {
case ^datetime.TZ_Region:
return v
case:
return nil
}
if cached, ok := tz_cache[name]; ok {
return cached, true
}
tz, ok := timezone.region_load(name)
if !ok {
return nil, false
}
tz_cache[name] = tz
return tz, true
}
destroy_tz_cache :: proc() {
for _, tz in tz_cache {
if tz != nil {
timezone.region_destroy(tz)
}
}
delete(tz_cache)
tz_cache = nil
// compute_utc_offset returns the UTC offset (in seconds) for the given
// timezone at the current time. Returns (0, true) if tz is nil.
compute_utc_offset :: proc(tz: ^datetime.TZ_Region) -> (offset: int, ok: bool) {
if tz == nil do return 0, true
tm := time.now()
dt_utc := time.time_to_datetime(tm) or_return
dt_local := timezone.datetime_to_tz(dt_utc, tz) or_return
tm_utc := time.datetime_to_time(dt_utc) or_return
tm_local := time.datetime_to_time(dt_local) or_return
offset = int(time.time_to_unix(tm_local) - time.time_to_unix(tm_utc))
return offset, true
}
// convert_to_tz converts date components from their source timezone to a
+11 -29
View File
@@ -2,6 +2,7 @@
package mustache
import "core:testing"
import "core:time/timezone"
// ---------------------------------------------------------------------------
// parse_iso_date
@@ -230,36 +231,15 @@ test_format_offset_positive :: proc(t: ^testing.T) {
}
// ---------------------------------------------------------------------------
// get_cached_tz / convert_to_tz (require system zoneinfo)
// convert_to_tz (require system zoneinfo)
// ---------------------------------------------------------------------------
@(test)
test_get_cached_tz_utc_returns_nil :: proc(t: ^testing.T) {
tz, ok := get_cached_tz("UTC")
testing.expect_value(t, ok, true)
testing.expect_value(t, tz == nil, true)
}
@(test)
test_get_cached_tz_empty_returns_nil :: proc(t: ^testing.T) {
tz, ok := get_cached_tz("")
testing.expect_value(t, ok, true)
testing.expect_value(t, tz == nil, true)
}
@(test)
test_get_cached_tz_loads_named_region :: proc(t: ^testing.T) {
tz, ok := get_cached_tz("America/New_York")
defer destroy_tz_cache()
if !ok do return
testing.expect(t, tz != nil, "should load TZ_Region for America/New_York")
}
@(test)
test_convert_to_tz_no_offset_assumes_target :: proc(t: ^testing.T) {
tz, tz_ok := get_cached_tz("America/New_York")
defer destroy_tz_cache()
tz, tz_ok := timezone.region_load("America/New_York", context.temp_allocator)
testing.expect(t, tz_ok, "should load timezone")
if !tz_ok do return
defer timezone.region_destroy(tz, context.temp_allocator)
c := Date_Components{
year = 2026, month = 3, day = 15,
@@ -274,9 +254,10 @@ test_convert_to_tz_no_offset_assumes_target :: proc(t: ^testing.T) {
@(test)
test_convert_to_tz_with_offset_converts :: proc(t: ^testing.T) {
tz, tz_ok := get_cached_tz("America/New_York")
defer destroy_tz_cache()
tz, tz_ok := timezone.region_load("America/New_York", context.temp_allocator)
testing.expect(t, tz_ok, "should load timezone")
if !tz_ok do return
defer timezone.region_destroy(tz, context.temp_allocator)
// 2026-03-15T12:49:54Z (UTC) → 08:49:54 EDT (UTC-4)
c := Date_Components{
@@ -294,9 +275,10 @@ test_convert_to_tz_with_offset_converts :: proc(t: ^testing.T) {
@(test)
test_convert_to_tz_with_negative_offset_converts :: proc(t: ^testing.T) {
tz, tz_ok := get_cached_tz("America/New_York")
defer destroy_tz_cache()
tz, tz_ok := timezone.region_load("America/New_York", context.temp_allocator)
testing.expect(t, tz_ok, "should load timezone")
if !tz_ok do return
defer timezone.region_destroy(tz, context.temp_allocator)
// 2026-03-15T08:49:54-04:00 → UTC 12:49:54 → EDT 08:49:54
c := Date_Components{
+6 -22
View File
@@ -5,6 +5,7 @@ import "core:log"
import "core:reflect"
import "core:strings"
import "core:time"
import "core:time/datetime"
// MAX_PIPES was chosen arbitrarily. It holds no performance or logical
// significance.
@@ -228,15 +229,8 @@ apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) ->
date_format = df
}
// Resolve timezone (optional — empty is fine, means display as-is)
tz_name := ""
if raw := resolve_name("timezone", ctx); raw != nil {
if s, s_ok := reflect.as_string(raw); s_ok {
tz_name = s
}
}
str2, err := apply_format(str, filter.args[:], pos, date_format, tz_name)
tz := resolve_tz(ctx)
str2, err := apply_format(str, filter.args[:], pos, date_format, tz)
if err != nil {
return value, err
} else {
@@ -257,7 +251,7 @@ apply_format :: proc(
args: []string,
pos: int,
date_format: string,
timezone_name: string,
tz: ^datetime.TZ_Region,
) -> (
result: string,
err: Error,
@@ -279,22 +273,12 @@ apply_format :: proc(
}
}
target_tz, tz_ok := get_cached_tz(timezone_name)
if !tz_ok {
return "", Error_Body {
msg = fmt.tprintf("unable to load timezone '%s'", timezone_name),
pos = pos,
kind = .Data,
}
}
if target_tz != nil {
components, _ = convert_to_tz(components, target_tz)
if tz != nil {
components, _ = convert_to_tz(components, tz)
} else if components.has_offset {
components.tz_abbr = format_offset(components.offset_seconds)
}
log.debugf("date: '%s' format: '%s' tz: '%s'", iso, date_format, timezone_name)
return format_date(components, fmt_str), nil
}
+16 -51
View File
@@ -3,6 +3,8 @@ package mustache
import "core:fmt"
import "core:testing"
import "core:time/datetime"
import "core:time/timezone"
Pipe_Post :: struct {
title: string,
@@ -209,7 +211,7 @@ test_interp_pipe_basic :: proc(t: ^testing.T) {
Scalar_Data :: struct {
name: string,
date_format: string,
timezone: string,
timezone: ^datetime.TZ_Region,
}
data := Scalar_Data {
name = "2026-03-15T08:49:54-04:00",
@@ -225,7 +227,7 @@ test_interp_pipe_unescaped :: proc(t: ^testing.T) {
Scalar_Data :: struct {
name: string,
date_format: string,
date_timezone: string,
timezone: ^datetime.TZ_Region,
}
data := Scalar_Data {
name = "2025-12-25T00:00:00Z",
@@ -241,7 +243,7 @@ test_interp_pipe_dot_current :: proc(t: ^testing.T) {
List_Data :: struct {
items: [3]string,
date_format: string,
timezone: string,
timezone: ^datetime.TZ_Region,
}
data := List_Data {
items = {"2026-01-06T00:00:00Z", "2026-06-15T00:00:00Z", "2026-10-15T00:00:00Z"},
@@ -259,7 +261,7 @@ test_interp_pipe_dot_current :: proc(t: ^testing.T) {
Format_Data :: struct {
date: string,
date_format: string,
timezone: string,
timezone: ^datetime.TZ_Region,
}
@(test)
@@ -488,44 +490,21 @@ test_format_bare_numeric_arg_treated_as_key_not_literal :: proc(t: ^testing.T) {
testing.expect(t, err != nil, "bare numeric-looking arg should error as an unresolved key")
}
@(test)
test_timezone_resolves_from_context :: proc(t: ^testing.T) {
TZ_Data :: struct {
timezone: string,
}
data := TZ_Data {
timezone = "America/New_York",
}
tpl, _ := parse("[{{timezone}}]", "<test>", allocator = context.temp_allocator)
result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "[America/New_York]")
}
@(test)
test_timezone_empty_when_not_set :: proc(t: ^testing.T) {
TZ_Data :: struct {
timezone: string,
}
data := TZ_Data {}
tpl, _ := parse("[{{timezone}}]", "<test>", allocator = context.temp_allocator)
result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "[]")
}
// ---------------------------------------------------------------------------
// format pipe with timezone conversion
// ---------------------------------------------------------------------------
@(test)
test_format_with_timezone_summer :: proc(t: ^testing.T) {
tz, tz_ok := get_cached_tz("America/New_York")
defer destroy_tz_cache()
if !tz_ok || tz == nil do return
tz, ok := timezone.region_load("America/New_York", context.temp_allocator)
testing.expect(t, ok, "should load timezone")
if !ok do return
defer timezone.region_destroy(tz, context.temp_allocator)
data := Format_Data {
date = "2026-03-15T12:49:54Z",
date_format = "15:04 MST",
timezone = "America/New_York",
timezone = tz,
}
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
result, _ := render(tpl, data, {}, context.temp_allocator)
@@ -534,14 +513,15 @@ test_format_with_timezone_summer :: proc(t: ^testing.T) {
@(test)
test_format_with_timezone_winter :: proc(t: ^testing.T) {
tz, tz_ok := get_cached_tz("America/New_York")
defer destroy_tz_cache()
if !tz_ok || tz == nil do return
tz, ok := timezone.region_load("America/New_York", context.temp_allocator)
testing.expect(t, ok, "should load timezone")
if !ok do return
defer timezone.region_destroy(tz, context.temp_allocator)
data := Format_Data {
date = "2026-01-15T12:49:54Z",
date_format = "15:04 MST",
timezone = "America/New_York",
timezone = tz,
}
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
result, _ := render(tpl, data, {}, context.temp_allocator)
@@ -569,18 +549,3 @@ test_format_mst_no_offset_no_timezone :: proc(t: ^testing.T) {
result, _ := render(tpl, data, {}, context.temp_allocator)
testing.expect_value(t, result, "UTC")
}
@(test)
test_format_invalid_timezone_errors :: proc(t: ^testing.T) {
defer destroy_tz_cache()
data := Format_Data {
date = "2026-03-15",
date_format = "2 Jan 2006",
timezone = "Invalid/Zone",
}
tpl, _ := parse("{{date | format}}", "<test>", allocator = context.temp_allocator)
defer delete_template(&tpl)
_, err := render(tpl, data, {}, context.temp_allocator)
testing.expect(t, err != nil, "invalid timezone should error")
}
+5 -5
View File
@@ -26,7 +26,7 @@ Base_Data :: struct {
description: string,
og: Open_Graph,
date_format: string,
timezone: string,
timezone: ^datetime.TZ_Region,
}
Page_Data :: struct {
@@ -157,10 +157,10 @@ render_site :: proc(site: ^Site) {
template_cache: map[string]mustache.Template
defer delete(template_cache)
// TODO: CAlculate offset
offset := 0
now, ok := time.time_to_rfc3339(time.now(), offset, false, allocator)
offset, ok := mustache.compute_utc_offset(site.tz)
assert(ok)
now, ok2 := time.time_to_rfc3339(time.now(), offset, false, allocator)
assert(ok2)
// Build base data once
base := Base_Data {
@@ -169,7 +169,7 @@ render_site :: proc(site: ^Site) {
description = site.description,
og = site.og,
date_format = site.date.format,
timezone = site.date.timezone,
timezone = site.tz,
}
// Find home page
+18 -1
View File
@@ -7,6 +7,8 @@ import "core:log"
import "core:mem"
import "core:os"
import "core:strings"
import "core:time/datetime"
import "core:time/timezone"
import md "markdown"
@@ -30,6 +32,7 @@ Site :: struct {
markdown_extensions: bit_set[md.Extension],
og: Open_Graph,
date: Date_Preferences,
tz: ^datetime.TZ_Region,
}
Date_Preferences :: struct {
@@ -115,8 +118,22 @@ init_site :: proc(site: ^Site, args: []string) {
site_apply_cli_flags(site, _flags)
site.config_path = path
// Build the resolved site-level OG now that every other field is set.
site.og = og_for_site(site)
tz_name := site.date.timezone
if tz_name == "" {
log.warnf("no timezone configured, falling back to local system timezone")
tz_name = "local"
}
tz, tz_ok := timezone.region_load(tz_name, alloc)
if tz_ok {
site.tz = tz
if site.date.timezone == "" {
log.debugf("detected local timezone: %s", tz.name)
}
} else if site.date.timezone != "" {
log.warnf("unable to load timezone '%s'", site.date.timezone)
}
}
load_config_file :: proc(