Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guides/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ per-call settings > handle compile profile > setup defaults > ClickHouse's
| setup defaults | `setup(defaults=…)` | the whole process, applied to every library when it opens |
| ClickHouse defaults | the vendored build | everything |

**The setup defaults latch.** `setup` is called once, before the first library opens, and the defaults it records are applied to each library as it loads and never again. A second `setup` with the same zone and defaults is a no-op; a different one is a `UsageError` naming both, and the first stands. If `setup` was never called, the first open commits the empty setup: no defaults, and the library's default zone, `UTC`. The latch closes only when a library completes its setup. If the first open fails there, because the library refuses the zone or a default, the record is cleared, so a corrected `setup` is accepted and the next open uses it ([`reference/bindings-v1.md` §6](../reference/bindings-v1.md#6-setup-and-from-a-request-to-a-loaded-library)). So call `setup` first. **There is no runtime setter in any binding.** 0.x's `set_default_settings` (and Go's `SetDefaultSettings`, which existed only on the linked build) is deleted ([`reference/bindings-v1.md` §7](../reference/bindings-v1.md#7-what-v0-api-is-deleted-and-why)); per-call settings and the compile profile carry everything that changes after setup. All four bindings reach all four channels now, Go included.
**The setup defaults latch.** `setup` is called once, before the first library opens, and the defaults it records are applied to each library as it loads and never again. A second `setup` with the same zone and defaults is a no-op; a different one is a `UsageError` naming both, and the first stands. If `setup` was never called, the first open commits the empty setup: no defaults, and the library's default zone, `UTC`. The latch closes only when a library completes its setup. Until then, an open that attempts a load and fails keeps the setup but unlocks it, whatever failed: the fetch, the signature, an artifact that will not load, or the library refusing the zone or a default. A retry with no new `setup` uses the recorded zone and defaults, and a different `setup` is accepted and replaces them. A refused version spelling, or an unverified open without its opt-ins, fails before any load and unlocks nothing ([`reference/bindings-v1.md` §6](../reference/bindings-v1.md#6-setup-and-from-a-request-to-a-loaded-library)). So call `setup` first. **There is no runtime setter in any binding.** 0.x's `set_default_settings` (and Go's `SetDefaultSettings`, which existed only on the linked build) is deleted ([`reference/bindings-v1.md` §7](../reference/bindings-v1.md#7-what-v0-api-is-deleted-and-why)); per-call settings and the compile profile carry everything that changes after setup. All four bindings reach all four channels now, Go included.

<details open><summary><b>Go</b></summary>

Expand Down
6 changes: 3 additions & 3 deletions docs/reference/bindings-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -427,10 +427,10 @@ A JSON object from handle kind (its type name, `chs_schema` and the rest) to the

The image zone and the default settings are process-wide in ClickHouse: vendored MergeTree code reads the server zone directly (`measured` by the artifact producer on 26.7), and defaults are read by every call. So both are chosen once, before traffic, through `setup`, and every binding gives `setup` the same rule:

1. **`setup` records; it does not load.** Called before any library is open, it records the zone spelling and the defaults. Called again with the same zone spelling, byte for byte, and the same defaults, it is a no-op. Called with a different zone or different defaults while a setup is recorded, it is a `UsageError` naming both, and the recorded setup stands. This is the library's own process-once rule for `chs_initialize`, applied by the binding while no image has been set up to ask. A record lasts until it latches (rule 2), or until a step 7 failure clears it (rule 4).
2. **The first successful step 7 latches it.** If `setup` was never called, the first open records the empty setup: the empty zone, which the library reads as `UTC`, and no defaults. The setup latches when the first image completes load step 7 successfully: `chs_initialize`, then `chs_set_defaults` when there are defaults. From then on `setup` succeeds only with exactly the setup in effect, and is otherwise a `UsageError` naming both. Call `setup` first.
1. **`setup` records; it does not load.** Called before any library is open, it records the zone spelling and the defaults. Called again with the same zone spelling, byte for byte, and the same defaults, it is a no-op. Called with a different zone or different defaults, it is a `UsageError` naming both, and the recorded setup stands, unless a failed open has unlocked the record (rule 4). So two components that disagree before any open is attempted get a refusal, never a silent last-wins. This is the library's own process-once rule for `chs_initialize`, applied by the binding while no image has been set up to ask.
2. **The first successful step 7 latches it.** If `setup` was never called, the first open records the empty setup: the empty zone, which the library reads as `UTC`, and no defaults. The setup latches when the first image completes load step 7 successfully: `chs_initialize`, then `chs_set_defaults` when there are defaults. From then on `setup` succeeds only with exactly the setup in effect, is otherwise a `UsageError` naming both, and nothing unlocks it again. Call `setup` first.
3. **Every image is set up at load step 7, once.** The loader calls `chs_initialize` with the recorded zone, as counted bytes (empty for the empty zone), and then, when there are defaults, `chs_set_defaults` with them, under the setup guard. Nothing calls either again for that image once step 7 has completed.
4. **What the library answers is what the caller gets, and a refused setup can be corrected.** A zone `DateLUT` will not load is a `SchemaError` from `chs_initialize`, and a default the library refuses is the error `chs_set_defaults` answers. Either is raised by the open that hit step 7, and that open returns no library. A failed step 7 is never remembered: the next open of the same image runs step 7 again. If no image has completed step 7 yet, the binding also clears its setup record, under the setup guard, so the caller may call `setup` again with any setup, and the next open runs step 7 with it. Once the setup has latched, a failure clears nothing. The library's own process-once rule stays the backstop: an image whose `chs_initialize` succeeded keeps its zone even if `chs_set_defaults` then failed, and a different spelling than the image already has (otherwise possible only across TypeScript isolates, or with a second binding loaded into one process) is `CHS_INVALID_ARGUMENT` from `chs_initialize`, a `UsageError` naming both, the same class rule 1 raises.
4. **What the library answers is what the caller gets, and a failed open can be corrected.** A zone `DateLUT` will not load is a `SchemaError` from `chs_initialize`, and a default the library refuses is the error `chs_set_defaults` answers. Either is raised by the open that hit step 7, and that open returns no library. **Until an image has completed step 7, an open that attempted a load and failed unlocks the setup record, whatever failed:** the resolve or the fetch, the signature, loader steps 1 to 6 (an incompatible or corrupt artifact, a missing symbol), or step 7 itself. The record is kept, so a retry with no new `setup` runs under the recorded zone and defaults, never under the empty setup. A different `setup` is accepted and replaces the record. A replaced record is locked again, and so is any record an open claims for its load. The binding unlocks the record under the setup guard, and never one that was recorded or replaced after the failed open began. The binding's own input checks fail before any load is attempted, and unlock nothing: a refused version spelling, or an unverified open without both opt-ins. A failed open is never remembered: the next open of the same request or image runs every step again. Once the setup has latched, a failure unlocks nothing. The library's own process-once rule stays the backstop: an image whose `chs_initialize` succeeded keeps its zone even if `chs_set_defaults` then failed, and a different spelling than the image already has (otherwise possible only across TypeScript isolates, or with a second binding loaded into one process) is `CHS_INVALID_ARGUMENT` from `chs_initialize`, a `UsageError` naming both, the same class rule 1 raises.
5. **Defaults never change during traffic.** There is no public setter, in any binding. If the regenerated description drops `chs_set_defaults` (the decision accepts "no setter at all"), the `defaults` field of `setup` is deleted with it, and per-call `settings` and the compile profile carry everything.

The per-call zone needs no setup: it is the `session_timezone` key of a call's settings (§2, the call options).
Expand Down
4 changes: 4 additions & 0 deletions go/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The four bindings in this repository are released together and give one answer,

## [Unreleased]

### Fixed

- An open that attempted a load and failed now unlocks the setup, whatever failed, while no library has completed load step 7 (#468). After `Setup(SetupOptions{Timezone: "Asia/Tokyo"})`, an open could fail before step 7: at the fetch, the signature check, an incompatible artifact (`CHTYPES_ARTIFACT_INCOMPATIBLE`) or a missing symbol. That left the setup locked, and a different `Setup` was a `*UsageError` until the process restarted. 1.0.2 handled only a failure in step 7 itself (#458). Now any failed attempt keeps the recorded setup but makes it replaceable. A plain retry with no new `Setup` runs under the recorded zone and defaults, never the empty setup. A different `Setup` is accepted and replaces the record, which is then locked again. This holds for `Registry.For`, `OpenUnverified` and `OpenLinked`. The caller's own misuse fails before any load is attempted and unlocks nothing: a refused version spelling, or `OpenUnverified(path, false)`. Two different `Setup` calls before any open is attempted are still a `*UsageError`, never a silent last-wins. Once a library has completed step 7, a different setup is still a `*UsageError`. No name or signature changes. All four bindings run the same cases, `tests/fixtures/abi-v1/setup-cases.json`, and the rule is `docs/reference/bindings-v1.md` section 6.

## [1.0.2] — 2026-10-06

### Changed
Expand Down
32 changes: 18 additions & 14 deletions go/chtypes/library.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ package chtypes
// exactly one ABI call and takes no lock.

import (
"errors"
"path/filepath"
"sync"

Expand Down Expand Up @@ -47,11 +46,11 @@ func imageKey(kind, path string) string {

// openImage loads (or returns the already loaded) image for key. Every image is
// set up at load step 7, once, under the process setup, which the first open
// records. images.mu is held from the commit to the settle, so every open
// loads under the record it settles, and Setup cannot change that record in
// between (it refuses a different setup while one is recorded). A step 7
// failure is the loader's *abi1.CallError, and only that clears the record;
// a failed load is never cached, so the next open runs step 7 again.
// records. images.mu is held from the commit to the latch, so an image latches
// the record it loaded under, and Setup cannot change that record in between
// (it refuses a different setup while one is recorded). A failed load is never
// cached, so the next open runs every step again; the open that called this
// settles a failure with failedOpen, whatever failed in the attempt.
func openImage(key string, load func(zone, defaults []byte) (*abi1.Table, error), path string, resolved *Resolved) (*Library, error) {
images.mu.Lock()
defer images.mu.Unlock()
Expand All @@ -63,16 +62,10 @@ func openImage(key string, load func(zone, defaults []byte) (*abi1.Table, error)
return l, nil
}
tbl, err := load(zone, defaults)
var step7 *abi1.CallError
switch {
case err == nil:
settleSetup(true)
case errors.As(err, &step7):
settleSetup(false)
}
if err != nil {
return nil, loadError(err)
}
latchSetup()
info, err := decodeBuildInfo([]byte(tbl.BuildInfo()))
if err != nil {
return nil, &ArtifactError{
Expand All @@ -96,7 +89,18 @@ func openImage(key string, load func(zone, defaults []byte) (*abi1.Table, error)
// CHTYPES_ALLOW_UNVERIFIED_LIBRARY=1 is set, and warns once per path. It runs
// step 7 under the process setup like any open, is not reachable through a
// Registry, and its Library has no Resolved record.
func OpenUnverified(path string, allow bool) (*Library, error) {
func OpenUnverified(path string, allow bool) (l *Library, err error) {
// The caller's two opt-ins are checked before anything is attempted, so a
// refusal here is misuse and unlocks nothing (bindings-v1.md section 6).
if err := abi1.CheckUnverifiedAllowed(path, allow); err != nil {
return nil, loadError(err)
}
gen := setupGeneration()
defer func() {
if err != nil {
failedOpen(gen)
}
}()
return openImage(imageKey("unverified", path), func(zone, defaults []byte) (*abi1.Table, error) {
return abi1.OpenUnverified(path, allow, zone, defaults)
}, path, nil)
Expand Down
8 changes: 7 additions & 1 deletion go/chtypes/linked.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ import "github.com/wave-rf/chtypes/go/internal/abi1"
// loader steps 3, 4, 6 and 7 (step 6 is satisfied by the linker) and skips 1,
// 2 and 5; one code path serves both modes, and only the table filler
// differs. It needs a core build tree via CGO_LDFLAGS, and -tags chtypes_linked.
func OpenLinked() (*Library, error) {
func OpenLinked() (l *Library, err error) {
gen := setupGeneration()
defer func() {
if err != nil {
failedOpen(gen)
}
}()
return openImage("linked", func(zone, defaults []byte) (*abi1.Table, error) {
return abi1.LoadLinked(abi1.OpenLinked(), zone, defaults)
}, "<linked>", nil)
Expand Down
17 changes: 16 additions & 1 deletion go/chtypes/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ package chtypes

import (
"context"
"errors"
"fmt"
"os"
"runtime"
Expand Down Expand Up @@ -125,16 +126,30 @@ func (r *Registry) ForContext(ctx context.Context, request string) (*Library, er
return r.open(ctx, request, r.autofetch)
}

func (r *Registry) open(ctx context.Context, request string, mayFetch bool) (*Library, error) {
func (r *Registry) open(ctx context.Context, request string, mayFetch bool) (_ *Library, err error) {
r.mu.Lock()
defer r.mu.Unlock()
if l := r.memo[request]; l != nil {
return l, nil
}
// An open that attempted a load and failed unlocks the setup record while
// no image has completed load step 7, whatever failed: the resolve, the
// fetch, the signature or any load step. A refused version spelling is the
// caller's own misuse, refused before anything is attempted, and unlocks
// nothing (bindings-v1.md section 6, rule 4).
gen := setupGeneration()
misuse := false
defer func() {
if err != nil && !misuse {
failedOpen(gen)
}
}()
opts := r.fetch.internal()
req := ocifetch.Request{Spelling: request}
res, err := ocifetch.ResolveInstalled(req, "", opts)
if err != nil {
var spelling *ocifetch.SpellingError
misuse = errors.As(err, &spelling)
return nil, fetchError(err)
}
if res == nil {
Expand Down
65 changes: 65 additions & 0 deletions go/chtypes/settings_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,68 @@ func TestFirstOpenCommitsTheEmptySetup(t *testing.T) {
t.Error("a setup after the first open commit must be refused: it is not the one in effect")
}
}

// TestAFailedFetchBeforeStep7UnlocksTheSetup: a registry open that attempted
// to resolve and failed (nothing installed, autofetch off) unlocks the setup
// record while no image has completed step 7, so a different setup replaces it.
// A refused version spelling is misuse, refused before anything is attempted,
// and unlocks nothing.
func TestAFailedFetchBeforeStep7UnlocksTheSetup(t *testing.T) {
resetSetup(t)
if err := Setup(SetupOptions{Timezone: "Asia/Tokyo"}); err != nil {
t.Fatal(err)
}
reg, err := NewRegistry(WithFetchOptions(FetchOptions{CacheDir: t.TempDir(), SystemDirs: []string{t.TempDir()}, Offline: true}), WithAutoFetch(false))
if err != nil {
t.Fatal(err)
}
var ue *UsageError
// A refused spelling first: it unlocks nothing.
if _, err := reg.For("v26.8"); !errors.As(err, &ue) {
t.Fatalf("For(v26.8) = %v, want a *UsageError", err)
}
if err := Setup(SetupOptions{Timezone: "UTC"}); !errors.As(err, &ue) {
t.Errorf("a different setup after a refused spelling = %v, want a *UsageError", err)
}
if _, err := reg.For("26.8"); !errors.Is(err, ErrArtifactMissing) {
t.Fatalf("For on an empty cache = %v, want ErrArtifactMissing", err)
}
// The failed open kept the record (a retry runs under it, never the empty
// setup) and made it replaceable.
setup.mu.Lock()
kept, replaceable := setup.recorded && setup.opts.Timezone == "Asia/Tokyo", setup.replaceable
setup.mu.Unlock()
if !kept || !replaceable {
t.Errorf("after a failed open: record kept = %v, replaceable = %v; want both", kept, replaceable)
}
if err := Setup(SetupOptions{Timezone: "UTC"}); err != nil {
t.Errorf("a different setup after a failed open = %v, want it to replace the record", err)
}
// The replaced record is locked again.
if err := Setup(SetupOptions{Timezone: "Europe/Berlin"}); !errors.As(err, &ue) {
t.Errorf("a second, different setup after the replacement = %v, want a *UsageError", err)
}
}

// TestAFailedOpenUnlocksOnlyItsOwnRecordAndNeverALatchedOne: a failed open
// never unlocks a setup recorded after it began, and once an image has
// completed step 7 a failure unlocks nothing.
func TestAFailedOpenUnlocksOnlyItsOwnRecordAndNeverALatchedOne(t *testing.T) {
resetSetup(t)
began := setupGeneration()
if err := Setup(SetupOptions{Timezone: "Asia/Tokyo"}); err != nil {
t.Fatal(err)
}
failedOpen(began) // began before Asia/Tokyo was recorded
if err := Setup(SetupOptions{Timezone: "UTC"}); err == nil {
t.Error("a failed open unlocked a setup recorded after it began")
}
latchSetup()
failedOpen(setupGeneration())
if err := Setup(SetupOptions{Timezone: "UTC"}); err == nil {
t.Error("a failed open unlocked a latched setup")
}
if err := Setup(SetupOptions{Timezone: "Asia/Tokyo"}); err != nil {
t.Errorf("the latched setup again = %v, want a no-op", err)
}
}
Loading