From 3ae044d2e1ea4690f4ac3c95646395978d4eadae Mon Sep 17 00:00:00 2001 From: SunSung-W541-2025 Date: Thu, 26 Mar 2026 18:38:28 +0100 Subject: [PATCH 1/2] refactor: improve thread safety with atomic operations and lock optimization - Replace bool field with atomic.Bool for thread-safe closed state checking - Optimize lock usage by using RLock where appropriate in Wait() and IsClosed() - Extract validation logic into validateLangAndWords helper function - Fix potential race condition in Get() by capturing log function before unlock - Add proper nil handling for SetLog() method with noop fallback - Improve code organization and reduce lock contention --- get.go | 3 ++- init.go | 40 +++++++++++++++++++++++++--------------- set.go | 18 +++++------------- set_test.go | 12 ++++++++++++ update.go | 20 ++++++-------------- 5 files changed, 50 insertions(+), 43 deletions(-) diff --git a/get.go b/get.go index 5701574..9504e0f 100644 --- a/get.go +++ b/get.go @@ -27,12 +27,13 @@ func (obj *LanguageWizardObj) Get(id, def string) string { obj.mx.RLock() val, ok := obj.words[id] + logFn := obj.log obj.mx.RUnlock() if ok { return val } - obj.log("undef: " + id) + logFn("undef: " + id) return def } diff --git a/init.go b/init.go index fc375f7..b7c5031 100644 --- a/init.go +++ b/init.go @@ -2,6 +2,7 @@ package language_wizard import ( "sync" + "sync/atomic" ) // // // // // // // // // // // // @@ -12,43 +13,52 @@ type LanguageWizardObj struct { mx sync.RWMutex changedCh chan struct{} - closed bool + closed atomic.Bool log func(string) } func New(isoLanguage string, words map[string]string) (*LanguageWizardObj, error) { - if isoLanguage == "" { - return nil, ErrNilIsoLang - } - if words == nil || len(words) == 0 { - return nil, ErrNilWords + if err := validateLangAndWords(isoLanguage, words); err != nil { + return nil, err } obj := new(LanguageWizardObj) obj.currentLanguage = isoLanguage - - copyWords := make(map[string]string, len(words)) - for k, v := range words { - copyWords[k] = v - } - obj.words = copyWords - + obj.words = cloneWords(words) obj.changedCh = make(chan struct{}) obj.log = func(s string) {} return obj, nil } +func validateLangAndWords(isoLanguage string, words map[string]string) error { + if isoLanguage == "" { + return ErrNilIsoLang + } + if len(words) == 0 { + return ErrNilWords + } + return nil +} + +func cloneWords(src map[string]string) map[string]string { + dst := make(map[string]string, len(src)) + for k, v := range src { + dst[k] = v + } + return dst +} + func (obj *LanguageWizardObj) Close() { obj.mx.Lock() defer obj.mx.Unlock() - if obj.closed { + if obj.closed.Load() { return } - obj.closed = true + obj.closed.Store(true) close(obj.changedCh) obj.words = make(map[string]string) diff --git a/set.go b/set.go index 29d78fd..d87ebfc 100644 --- a/set.go +++ b/set.go @@ -4,7 +4,7 @@ package language_wizard func (obj *LanguageWizardObj) SetLog(f func(string)) { if f == nil { - return + f = func(string) {} } obj.mx.Lock() @@ -14,17 +14,14 @@ func (obj *LanguageWizardObj) SetLog(f func(string)) { } func (obj *LanguageWizardObj) SetLanguage(isoLanguage string, words map[string]string) error { - if isoLanguage == "" { - return ErrNilIsoLang - } - if words == nil || len(words) == 0 { - return ErrNilWords + if err := validateLangAndWords(isoLanguage, words); err != nil { + return err } obj.mx.Lock() defer obj.mx.Unlock() - if obj.closed { + if obj.closed.Load() { return ErrClosed } @@ -33,12 +30,7 @@ func (obj *LanguageWizardObj) SetLanguage(isoLanguage string, words map[string]s } obj.currentLanguage = isoLanguage - - copyWords := make(map[string]string, len(words)) - for k, v := range words { - copyWords[k] = v - } - obj.words = copyWords + obj.words = cloneWords(words) close(obj.changedCh) obj.changedCh = make(chan struct{}) diff --git a/set_test.go b/set_test.go index f229d46..7dd61e9 100644 --- a/set_test.go +++ b/set_test.go @@ -52,3 +52,15 @@ func TestSetLog_AllowsCustomLogger(t *testing.T) { t.Fatalf("custom logger was not called") } } + +func TestSetLog_NilResetsToNoop(t *testing.T) { + obj := mustNew(t) + + obj.SetLog(func(s string) { t.Errorf("logger should not be called after reset, got: %s", s) }) + obj.SetLog(nil) + + // Must not panic and must return default value + if got := obj.Get("nonexistent", "def"); got != "def" { + t.Fatalf("Get after SetLog(nil) = %q, want %q", got, "def") + } +} diff --git a/update.go b/update.go index f6f9c05..a88df7b 100644 --- a/update.go +++ b/update.go @@ -10,33 +10,25 @@ const ( ) func (obj *LanguageWizardObj) WaitChan() chan struct{} { - obj.mx.Lock() + obj.mx.RLock() ch := obj.changedCh - obj.mx.Unlock() + obj.mx.RUnlock() return ch } func (obj *LanguageWizardObj) IsClosed() bool { - obj.mx.RLock() - closed := obj.closed - obj.mx.RUnlock() - - return closed + return obj.closed.Load() } func (obj *LanguageWizardObj) Wait() EventType { - obj.mx.Lock() + obj.mx.RLock() ch := obj.changedCh - obj.mx.Unlock() + obj.mx.RUnlock() <-ch - obj.mx.RLock() - closed := obj.closed - obj.mx.RUnlock() - - if closed { + if obj.closed.Load() { return EventClose } return EventLanguageChanged From d051d5e126478da419a8f47886d83986bf2e7d1f Mon Sep 17 00:00:00 2001 From: SunSung-W541-2025 Date: Thu, 26 Mar 2026 18:51:18 +0100 Subject: [PATCH 2/2] docs: add Russian translation and improve documentation structure - Add Russian version of README (README.RU.md) - Add language version link at top of English README - Enhance documentation with object lifecycle diagram using Mermaid - Improve formatting and structure for better readability - Maintain all existing content while adding internationalization support --- README.RU.md | 460 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 219 ++++++++++++++++++------ 2 files changed, 629 insertions(+), 50 deletions(-) create mode 100644 README.RU.md diff --git a/README.RU.md b/README.RU.md new file mode 100644 index 0000000..c873145 --- /dev/null +++ b/README.RU.md @@ -0,0 +1,460 @@ +[![Go Report Card](https://goreportcard.com/badge/github.com/voluminor/language_wizard)](https://goreportcard.com/report/github.com/voluminor/language_wizard) + +![GitHub repo file or directory count](https://img.shields.io/github/directory-file-count/voluminor/language_wizard?color=orange) +![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/voluminor/language_wizard?color=green) +![GitHub repo size](https://img.shields.io/github/repo-size/voluminor/language_wizard) + +> [English version](README.md) + +# language-wizard + +*Маленькое потокобезопасное i18n хранилище ключ–значение с горячей сменой языка и простой моделью событий.* + +## Обзор + +`language-wizard` — минималистичный помощник для приложений, которым нужна простая словарная i18n. Хранит текущий +ISO-код языка и словарь строк перевода в памяти, позволяет атомарно переключать активный язык и предоставляет +небольшой механизм событий для фоновых горутин, которые должны реагировать на смену языка или закрытие объекта. +Внутреннее состояние защищено `sync.RWMutex` для конкурентного доступа. + +### Жизненный цикл объекта + +```mermaid +stateDiagram-v2 + [*] --> Active: New(isoLang, words)\n✓ возвращает *LanguageWizardObj + Active --> Active: SetLanguage(lang, words)\n• заменяет словарь\n• закрывает changedCh\n• создаёт новый changedCh\n• уведомляет всех ожидающих + Active --> Closed: Close()\n• closed.Store(true)\n• close(changedCh)\n• очищает словарь + Closed --> Closed: Close()\nидемпотентно — no-op + Active --> Active: Get / CurrentLanguage / Words\nтолько чтение, состояние не меняют + Closed --> Closed: Get / CurrentLanguage / Words\nработают, но словарь пуст + note right of Closed + SetLanguage → ErrClosed + Wait → EventClose (немедленно) + end note +``` + +## Возможности + +* **Простой словарь ключ–значение** для переводов. +* **Горячая смена языка** с атомарной заменой словаря. +* **Потокобезопасное чтение/запись** через RWMutex. +* **Защитное копирование** при передаче словаря вызывающему коду. +* **Блокирующее ожидание** смены языка или закрытия через модель событий. +* **Подключаемый логгер** для отсутствующих ключей. + +## Установка + +```bash +go get github.com/voluminor/language_wizard +``` + +Или скопируйте пакет `language_wizard` в дерево исходников вашего проекта. + +## Быстрый старт + +```go +package main + +import ( + "fmt" + "log" + "github.com/voluminor/language_wizard" +) + +func main() { + obj, err := language_wizard.New("en", map[string]string{ + "hi": "Hello", + }) + if err != nil { + log.Fatal(err) + } + + // Поиск с дефолтным значением + fmt.Println(obj.Get("hi", "DEF")) // "Hello" + fmt.Println(obj.Get("bye", "Bye")) // "Bye" (и логирует "undef: bye") + + // Опционально: подключить логгер для пропущенных ключей + obj.SetLog(func(s string) { + log.Printf("language-wizard: %s", s) + }) + + // Смена языка во время работы + _ = obj.SetLanguage("de", map[string]string{ + "hi": "Hallo", + }) + + fmt.Println(obj.CurrentLanguage()) // "de" + fmt.Println(obj.Get("hi", "DEF")) // "Hallo" +} +``` + +`New` проверяет, что ISO-код языка не пустой, а словарь не равен `nil` и не пустой. Переданный словарь +защитно копируется. +`Get` возвращает дефолтное значение, если ключ пустой или отсутствует, и логирует неизвестные ключи через +настроенный логгер. + +## Концепции и API + +### Создание + +```go +obj, err := language_wizard.New(isoLanguage string, words map[string]string) +``` + +* Возвращает `ErrNilIsoLang`, если `isoLanguage` пустой. +* Возвращает `ErrNilWords`, если `words` равен `nil` или пустой. +* При успехе сохраняет код языка и **копию** `words`, инициализирует внутренний канал событий и устанавливает + логгер-заглушку (no-op). + +### Чтение + +```go +lang := obj.CurrentLanguage() // возвращает текущий ISO-код +m := obj.Words() // возвращает КОПИЮ словаря +v := obj.Get(id, def) // возвращает def, если ключ пустой или отсутствует +``` + +* `CurrentLanguage` и `Words` берут read-блокировку; `Words` возвращает защитную копию, чтобы внешние изменения + не затронули внутреннее состояние. +* `Get` логирует промахи в формате `"undef: "` через настроенный логгер и возвращает переданное дефолтное + значение. + +#### Схема работы `Get` + +```mermaid +flowchart TD + A([Get(id, def)]) --> B{id == ""?} +B -- да --> Z1([вернуть def]) +B -- нет --> C[RLock] +C --> D[val, ok = words[id]\nlogFn = obj.log] +D --> E[RUnlock] +E --> F{ok?} +F -- да --> Z2([вернуть val]) +F -- нет --> G[logFn("undef: " + id)] +G --> Z3([вернуть def]) +``` + +> `logFn` снимается под тем же `RLock`, что и поиск по словарю, поэтому конкурентные вызовы `SetLog` +> не могут создать гонку данных. + +### Обновление + +```go +err := obj.SetLanguage(isoLanguage string, words map[string]string) +``` + +* Валидирует входные данные как в `New`; возвращает `ErrNilIsoLang` / `ErrNilWords` при невалидных значениях. +* Возвращает `ErrClosed`, если объект был закрыт. +* Возвращает `ErrLangAlreadySet`, если `isoLanguage` совпадает с текущим. +* При успехе **атомарно заменяет** язык и **копию** переданного словаря, **закрывает** внутренний канал событий + для уведомления ожидающих горутин, затем создаёт **новый канал** для будущих ожиданий. + +### События и ожидание + +```go +type EventType byte + +const ( +EventClose EventType = 0 +EventLanguageChanged EventType = 4 +) + +ev := obj.Wait() // блокирует до смены языка или закрытия объекта +ok := obj.WaitUntilClosed() // true, если объект был закрыт +``` + +* `Wait` снимает снимок текущего канала под коротким `RLock`, блокируется на нём, затем атомарно проверяет флаг + `closed`, чтобы вернуть `EventClose` или `EventLanguageChanged`. +* `WaitUntilClosed` — удобная обёртка, возвращающая `true`, если получено событие закрытия. + +#### Схема уведомления при SetLanguage + +```mermaid +sequenceDiagram + participant C as Вызывающий код + participant W as LanguageWizardObj + participant G1 as Горутина A (Wait) + participant G2 as Горутина B (Wait) + G1 ->> W: Wait() + W -->> G1: RLock → снимок ch₀ → RUnlock + G1 ->> G1: блокировка на ← ch₀ + G2 ->> W: Wait() + W -->> G2: RLock → снимок ch₀ → RUnlock + G2 ->> G2: блокировка на ← ch₀ + C ->> W: SetLanguage("de", words) + W ->> W: Lock + W ->> W: валидация + обновление currentLanguage + cloneWords + W ->> W: close(ch₀) ← разблокирует G1 и G2 + W ->> W: ch₁ = make(chan struct{}) + W ->> W: Unlock + W -->> G1: ← ch₀ сработал → closed.Load()=false → EventLanguageChanged + W -->> G2: ← ch₀ сработал → closed.Load()=false → EventLanguageChanged + Note over G1, G2: Обе горутины снова вызывают Wait(),\nна этот раз блокируясь на новом ch₁ +``` + +#### Схема уведомления при Close + +```mermaid +sequenceDiagram + participant C as Вызывающий код + participant W as LanguageWizardObj + participant G1 as Горутина A (Wait) + participant G2 as Горутина B (Wait) + G1 ->> W: Wait() + W -->> G1: снимок ch₀ → блокировка на ← ch₀ + G2 ->> W: WaitChan() + W -->> G2: RLock → снимок ch₀ → RUnlock + G2 ->> G2: блокировка на ← ch₀ (в select) + C ->> W: Close() + W ->> W: Lock + W ->> W: closed.Store(true) + W ->> W: close(ch₀) ← разблокирует G1 и G2 + W ->> W: words = пустой map + W ->> W: Unlock + Note right of W: ch₀ остаётся закрытым навсегда,\nновый канал не создаётся + W -->> G1: ← ch₀ сработал → closed.Load()=true → EventClose + W -->> G2: ← ch₀ сработал → IsClosed()=true → обработать закрытие + Note over G1, G2: Любой последующий Wait() / ← WaitChan()\nвернётся немедленно (закрытый канал) +``` + +**Типичный цикл:** + +```go +go func () { +for { +switch obj.Wait() { +case language_wizard.EventLanguageChanged: +// Пересобрать кэши / обновить UI здесь. +case language_wizard.EventClose: +// Очистить ресурсы и выйти. +return +} +} +}() +``` + +**Цикл с контекстом:** + +```go +go func () { +for { +select { +case <-ctx.Done(): +return +case <-obj.WaitChan(): +if obj.IsClosed() { +// Очистить ресурсы и выйти. +return +} +// Пересобрать кэши / обновить UI здесь. +} +} +}() +``` + +> Каждая итерация вызывает `obj.WaitChan()`, получая свежий снимок текущего канала — цикл корректно +> переключается на новый канал после каждого `SetLanguage`. + +### Логирование + +```go +obj.SetLog(func (msg string) { /* ... */ }) +``` + +* Устанавливает пользовательский логгер для промахов при поиске ключа. Передача `nil` сбрасывает логгер обратно + на встроенную заглушку (no-op). Логгер сохраняется под write-блокировкой. +* Логгер вызывается только в `Get` (при промахе). + +### Закрытие + +```go +obj.Close() +``` + +* Идемпотентно. Устанавливает флаг `closed`, **закрывает канал событий** (разблокируя все `Wait`), и очищает + словарь до пустого map. Последующие вызовы `SetLanguage` вернут `ErrClosed`. + +### Ошибки + +Экспортированные ошибки: + +* `ErrNilIsoLang` — ISO-код языка обязателен в `New`/`SetLanguage`. +* `ErrNilWords` — `words` должен быть не-nil и не пустым в `New`/`SetLanguage`. +* `ErrLangAlreadySet` — попытка установить тот же язык, что уже активен. +* `ErrClosed` — объект закрыт; обновления недопустимы. + +## Потокобезопасность и модель конкурентности + +```mermaid +graph LR + subgraph Readers ["Read lock (RLock) — конкурентный доступ"] + CL["CurrentLanguage()"] + WO["Words()"] + GE["Get()"] + WC["WaitChan()"] + WA["Wait() — только снимок канала"] + end + + subgraph Writers ["Write lock (Lock) — эксклюзивный доступ"] + SL["SetLanguage()"] + SLG["SetLog()"] + CL2["Close()"] + end + + subgraph Atomic ["Без блокировки — атомарная операция"] + IC["IsClosed()"] + WA2["Wait() — проверка флага closed"] + end + + MX["sync.RWMutex"] --> Readers + MX --> Writers + AB["atomic.Bool (closed)"] --> Atomic +``` + +Ключевые гарантии: + +* `SetLanguage` **закрывает** текущий канал событий для уведомления всех ожидающих, затем сразу **заменяет** его + новым каналом — последующие `Wait` будут блокироваться до следующего события. +* `Wait` и `WaitChan` снимают снимок канала под минимальным `RLock` — блокировка снимается до начала ожидания, + поэтому ожидающие горутины никогда не конкурируют с писателями. +* Флаг `closed` — это `atomic.Bool`: чтение (`IsClosed`, проверка после разблокировки в `Wait`) не требует + никакой блокировки. +* `Get` снимает под одним `RLock` как значение из словаря, так и указатель на функцию `log` — это предотвращает + гонку с конкурентными вызовами `SetLog`. + +## Паттерны использования + +### 1) HTTP-обработчики / CLI: получение с дефолтом + +```go +func greet(obj *language_wizard.LanguageWizardObj) string { +return obj.Get("hi", "Hello") +} +``` + +Защищает от отсутствующих ключей, при этом выводя их в лог через логгер. + +### 2) Слежение за сменой языка + +```go +func watch(obj *language_wizard.LanguageWizardObj) { +for { +switch obj.Wait() { +case language_wizard.EventLanguageChanged: +// например, прогреть шаблоны или инвалидировать кэши +case language_wizard.EventClose: +return +} +} +} +``` + +Запускайте из горутины, чтобы держать вспомогательное состояние в синхронизации с активным языком. + +### 3) Горячая замена языка во время работы + +```go +_ = obj.SetLanguage("fr", map[string]string{"hi": "Bonjour"}) +``` + +Все текущие ожидающие горутины получат уведомление; последующие ожидания переключатся на новый канал. + +### 4) Пользовательский логгер для отсутствующих ключей + +```go +obj.SetLog(func (s string) { +// s выглядит так: "undef: some.missing.key" +}) +``` + +Удобно для сбора телеметрии по пропущенным переводам. Передайте `nil`, чтобы сбросить логгер обратно на заглушку. + +## Тестирование + +Запустите тесты с детектором гонок: + +```bash +go test -race ./... +``` + +Что покрывается тестами: + +* Успешное создание и базовый поиск по ключам. +* Семантика защитного копирования для `Words()`. +* Возврат дефолтного значения в `Get` и логирование промахов. +* `SetLog(nil)` сбрасывает логгер на заглушку без паники. +* Валидация и обработка ошибок в `New`/`SetLanguage`. +* Смена языка и обновление текущего языка. +* Обработка событий: `Wait`, `WaitUntilClosed` и поведение при закрытии. +* `Close` очищает словарь и блокирует дальнейшие обновления. + +## FAQ + +**В: Почему `Wait` иногда возвращается немедленно при повторном вызове?** +Потому что `SetLanguage` и `Close` **закрывают** текущий канал событий; если вызвать `Wait` снова без последующего +`SetLanguage`, вы всё ещё можете наблюдать уже закрытый канал. Реализация **заменяет** канал после закрытия; +вызывайте `Wait` в цикле и воспринимайте каждый возврат как единственное событие. + +**В: Можно ли изменять map, возвращённый `Words()`?** +Да, это копия. Её изменение не затронет внутреннее состояние. Используйте `SetLanguage` для замены внутреннего +словаря. + +**В: Что происходит после `Close()`?** +`Wait` разблокируется с `EventClose`, словарь очищается, а `SetLanguage` возвращает `ErrClosed`. Методы чтения +продолжают работать, но словарь пуст, если вы не сохранили внешнюю копию. + +## Важные особенности поведения + +### `Get` и `CurrentLanguage` на закрытом объекте + +После вызова `Close()` методы чтения (`Get`, `CurrentLanguage`, `Words`) остаются полностью работоспособными и +**не** возвращают ошибок и не вызывают панику. Однако `Close()` очищает внутренний словарь до пустого map, поэтому: + +* `Get(id, def)` будет **всегда возвращать `def`** для любого ключа и логировать `"undef: "` при каждом + вызове. +* `CurrentLanguage()` по-прежнему вернёт **последний код языка**, установленный до закрытия, даже если объект + больше не пригоден для обновлений. +* `Words()` вернёт **пустой map**. + +Это означает, что по возвращаемому значению `Get` невозможно отличить "ключ действительно отсутствует в текущем +переводе" от "объект был закрыт". Если ваш код должен обнаруживать закрытие, явно проверяйте `IsClosed()`: + +```go +if obj.IsClosed() { +// обработать закрытое состояние +return +} +val := obj.Get("greeting", "Hello") +``` + +### Поведение `Wait` после `Close()` + +После вызова `Close()` внутренний канал событий закрывается навсегда и **никогда не заменяется**. Это имеет +следующие последствия: + +* **Первый** вызов `Wait()`, заблокированный в момент `Close()`, корректно разблокируется и вернёт `EventClose`. +* Любые **последующие** вызовы `Wait()` после `Close()` также вернут `EventClose` **немедленно** (чтение из + закрытого канала в Go возвращает нулевое значение без блокировки). +* Если ваш код вызывает `Wait()` в цикле, он будет **крутиться вхолостую бесконечно** после закрытия, если + явно не проверять `EventClose` и не выходить: + +```go +for { +switch obj.Wait() { +case language_wizard.EventLanguageChanged: +// обработать смену языка +case language_wizard.EventClose: +return // ВАЖНО: здесь необходимо выйти из цикла +} +} +``` + +Без `return` (или `break`) на `EventClose` цикл превращается в активное ожидание, потребляющее 100% ядра +процессора, так как `Wait()` больше никогда не блокируется после закрытия объекта. + +## Ограничения + +* Только словарная i18n: нет правил ICU/plural, интерполяции или цепочек fallback — намеренно минималистично. +* `Wait()` не принимает параметр таймаута; используйте `WaitChan()` с `select` и `ctx.Done()` для + отменяемых ожиданий. +* Сравнение языков строковое; `SetLanguage("en", …)` при уже активном `"en"` вернёт `ErrLangAlreadySet`. diff --git a/README.md b/README.md index f9496df..008e94f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ ![GitHub code size in bytes](https://img.shields.io/github/languages/code-size/voluminor/language_wizard?color=green) ![GitHub repo size](https://img.shields.io/github/repo-size/voluminor/language_wizard) +> [Русская версия](README.RU.md) + # language-wizard *A tiny, thread-safe i18n key–value store with hot language switching and a simple event model.* @@ -13,15 +15,31 @@ `language-wizard` is a minimalistic helper for applications that need a simple dictionary-based i18n. It stores the current ISO language code and an in-memory map of translation strings, lets you switch the active language atomically, and exposes a small event mechanism so background workers can react to changes or closure. The internal state is guarded -by a `sync.RWMutex` for concurrent access. +by a `sync.RWMutex` for concurrent access. + +### Object Lifecycle + +```mermaid +stateDiagram-v2 + [*] --> Active: New(isoLang, words)\n✓ returns *LanguageWizardObj + Active --> Active: SetLanguage(lang, words)\n• swaps dictionary\n• closes changedCh\n• creates fresh changedCh\n• notifies all waiters + Active --> Closed: Close()\n• closed.Store(true)\n• close(changedCh)\n• clears words map + Closed --> Closed: Close()\nidempotent — no-op + Active --> Active: Get / CurrentLanguage / Words\nread-only, never change state + Closed --> Closed: Get / CurrentLanguage / Words\nstill work, but words is empty + note right of Closed + SetLanguage → ErrClosed + Wait → EventClose (immediate) + end note +``` ## Features * **Simple key–value dictionary** for translations. -* **Hot language switching** with atomic swap of the dictionary. -* **Thread-safe reads/writes** guarded by a RWMutex. -* **Defensive copy** when exposing the map to callers. -* **Blocking wait** for language changes or closure via a tiny event model. +* **Hot language switching** with atomic swap of the dictionary. +* **Thread-safe reads/writes** guarded by a RWMutex. +* **Defensive copy** when exposing the map to callers. +* **Blocking wait** for language changes or closure via a tiny event model. * **Pluggable logger** for missing keys. ## Installation @@ -30,7 +48,7 @@ by a `sync.RWMutex` for concurrent access. go get github.com/voluminor/language_wizard ``` -Or vendor/copy the `language_wizard` package into your project’s source tree. +Or vendor/copy the `language_wizard` package into your project's source tree. ## Quick Start @@ -71,8 +89,8 @@ func main() { ``` `New` validates that the ISO language is not empty and the words map is non-nil and non-empty. The initial map is -defensively copied. -`Get` returns a default when the key is empty or missing and logs undefined keys via the configured logger. +defensively copied. +`Get` returns a default when the key is empty or missing and logs undefined keys via the configured logger. ## Concepts & API @@ -85,7 +103,7 @@ obj, err := language_wizard.New(isoLanguage string, words map[string]string) * Fails with `ErrNilIsoLang` if `isoLanguage` is empty. * Fails with `ErrNilWords` if `words` is `nil` or empty. * On success, stores the language code and a **copy** of `words`, initializes an internal change channel, and sets a - no-op logger. + no-op logger. ### Reading @@ -96,8 +114,25 @@ v := obj.Get(id, def) // returns def if id is empty or missing ``` * `CurrentLanguage` and `Words` take read locks; `Words` returns a defensive copy so external modifications cannot - mutate internal state. -* `Get` logs misses in the form `"undef: "` via `obj.log` and returns the provided default. + mutate internal state. +* `Get` logs misses in the form `"undef: "` via the configured logger and returns the provided default. + +#### `Get` flow + +```mermaid +flowchart TD + A([Get(id, def)]) --> B{id == ""?} +B -- yes --> Z1([return def]) +B -- no --> C[RLock] +C --> D[val, ok = words[id]\nlogFn = obj.log] +D --> E[RUnlock] +E --> F{ok?} +F -- yes --> Z2([return val]) +F -- no --> G[logFn("undef: " + id)] +G --> Z3([return def]) +``` + +> `logFn` is snapshotted under the same `RLock` as the map lookup, so concurrent `SetLog` calls cannot race with it. ### Updating @@ -105,11 +140,11 @@ v := obj.Get(id, def) // returns def if id is empty or missing err := obj.SetLanguage(isoLanguage string, words map[string]string) ``` -* Validates input as in `New`; returns `ErrNilIsoLang` / `ErrNilWords` on invalid values. -* Returns `ErrClosed` if the object was closed. -* Returns `ErrLangAlreadySet` if `isoLanguage` equals the current one. +* Validates input as in `New`; returns `ErrNilIsoLang` / `ErrNilWords` on invalid values. +* Returns `ErrClosed` if the object was closed. +* Returns `ErrLangAlreadySet` if `isoLanguage` equals the current one. * On success, **atomically swaps** the language and a **copy** of the provided map, **closes** the internal change - channel to notify waiters, then creates a **fresh channel** for future waits. + channel to notify waiters, then creates a **fresh channel** for future waits. ### Events & Waiting @@ -125,9 +160,59 @@ ev := obj.Wait() // blocks until language changes or object is closed ok := obj.WaitUntilClosed() // true if it was closed, false otherwise ``` -* `Wait` blocks on the internal channel. When it unblocks, it inspects the `closed` flag: `EventClose` if closed, - otherwise `EventLanguageChanged`. -* `WaitUntilClosed` is a convenience that returns `true` iff the closure event was received. +* `Wait` snapshots the current channel under a short `RLock`, blocks on it, then checks the `closed` flag atomically to + distinguish `EventClose` from `EventLanguageChanged`. +* `WaitUntilClosed` is a convenience that returns `true` iff the closure event was received. + +#### SetLanguage notification flow + +```mermaid +sequenceDiagram + participant C as Caller + participant W as LanguageWizardObj + participant G1 as Goroutine A (Wait) + participant G2 as Goroutine B (Wait) + G1 ->> W: Wait() + W -->> G1: RLock → snapshot ch₀ → RUnlock + G1 ->> G1: blocking on ← ch₀ + G2 ->> W: Wait() + W -->> G2: RLock → snapshot ch₀ → RUnlock + G2 ->> G2: blocking on ← ch₀ + C ->> W: SetLanguage("de", words) + W ->> W: Lock + W ->> W: validate + update currentLanguage + cloneWords + W ->> W: close(ch₀) ← unblocks G1 and G2 + W ->> W: ch₁ = make(chan struct{}) + W ->> W: Unlock + W -->> G1: ← ch₀ fires → closed.Load()=false → EventLanguageChanged + W -->> G2: ← ch₀ fires → closed.Load()=false → EventLanguageChanged + Note over G1, G2: Both goroutines call Wait() again,\nthis time blocking on the fresh ch₁ +``` + +#### Close notification flow + +```mermaid +sequenceDiagram + participant C as Caller + participant W as LanguageWizardObj + participant G1 as Goroutine A (Wait) + participant G2 as Goroutine B (Wait) + G1 ->> W: Wait() + W -->> G1: snapshot ch₀ → blocking on ← ch₀ + G2 ->> W: WaitChan() + W -->> G2: RLock → snapshot ch₀ → RUnlock + G2 ->> G2: blocking on ← ch₀ (in select) + C ->> W: Close() + W ->> W: Lock + W ->> W: closed.Store(true) + W ->> W: close(ch₀) ← unblocks G1 and G2 + W ->> W: words = empty map + W ->> W: Unlock + Note right of W: ch₀ stays closed forever,\nno fresh channel is created + W -->> G1: ← ch₀ fires → closed.Load()=true → EventClose + W -->> G2: ← ch₀ fires → IsClosed()=true → handle closure + Note over G1, G2: Any future Wait() / ← WaitChan()\nreturns immediately (closed channel) +``` **Typical loop:** @@ -149,29 +234,33 @@ return ```go go func () { +for { select { case <-ctx.Done(): return case <-obj.WaitChan(): -if obj.IsClosed(){ +if obj.IsClosed() { // Cleanup and exit. return } - // Rebuild caches / refresh UI here. } } }() ``` +> Each iteration calls `obj.WaitChan()` to get a fresh snapshot of the current channel, so the loop correctly +> latches onto the new channel after every `SetLanguage`. + ### Logging ```go obj.SetLog(func (msg string) { /* ... */ }) ``` -* Sets a custom logger for undefined key lookups; `nil` is ignored. The logger is stored under a write lock. -* Only `Get` calls the logger (for misses). +* Sets a custom logger for undefined key lookups. Passing `nil` resets the logger back to the built-in no-op. + The logger is stored under a write lock. +* Only `Get` calls the logger (for misses). ### Closing @@ -179,26 +268,55 @@ obj.SetLog(func (msg string) { /* ... */ }) obj.Close() ``` -* Idempotent. Sets `closed`, **closes the change channel** (unblocking `Wait`), and clears the words map to an empty - one. Further `SetLanguage` calls will fail with `ErrClosed`. +* Idempotent. Sets the `closed` flag, **closes the change channel** (unblocking all `Wait` calls), and clears the words + map to an empty one. Further `SetLanguage` calls will fail with `ErrClosed`. ### Errors Exported errors: -* `ErrNilIsoLang` — ISO language is required by `New`/`SetLanguage`. -* `ErrNilWords` — `words` must be non-nil and non-empty in `New`/`SetLanguage`. -* `ErrLangAlreadySet` — attempted to set the same language as current. -* `ErrClosed` — the object has been closed; updates are not allowed. +* `ErrNilIsoLang` — ISO language is required by `New`/`SetLanguage`. +* `ErrNilWords` — `words` must be non-nil and non-empty in `New`/`SetLanguage`. +* `ErrLangAlreadySet` — attempted to set the same language as current. +* `ErrClosed` — the object has been closed; updates are not allowed. ## Thread-Safety & Concurrency Model -* The struct holds a `sync.RWMutex`; readers (`CurrentLanguage`, `Words`, `Get`) take an RLock; - writers (`SetLanguage`, `SetLog`, `Close`) take a Lock. +```mermaid +graph LR + subgraph Readers ["Read lock (RLock) — concurrent"] + CL["CurrentLanguage()"] + WO["Words()"] + GE["Get()"] + WC["WaitChan()"] + WA["Wait() — channel snapshot only"] + end + + subgraph Writers ["Write lock (Lock) — exclusive"] + SL["SetLanguage()"] + SLG["SetLog()"] + CL2["Close()"] + end + + subgraph Atomic ["No lock — atomic operation"] + IC["IsClosed()"] + WA2["Wait() — closed flag check"] + end + + MX["sync.RWMutex"] --> Readers + MX --> Writers + AB["atomic.Bool (closed)"] --> Atomic +``` + +Key invariants: + * `SetLanguage` **closes** the current change channel to notify all waiters, then immediately **replaces** it with a new - channel so subsequent `Wait` calls will block until the next event. -* `Wait` reads a snapshot of the channel under a short lock, waits on it, then distinguishes “close” vs - “language-changed” by checking the `closed` flag under an RLock. + channel so subsequent `Wait` calls will block until the next event. +* `Wait` and `WaitChan` snapshot the channel under a minimal `RLock` — the lock is released before blocking, + so waiters never contend with writers. +* The `closed` flag is an `atomic.Bool`: reads (`IsClosed`, post-wait check in `Wait`) require no lock at all. +* `Get` snapshots both the map value and the `log` function pointer under a single `RLock`, preventing a race with + concurrent `SetLog` calls. ## Usage Patterns @@ -210,7 +328,7 @@ return obj.Get("hi", "Hello") } ``` -This shields you from missing keys while still surfacing them via the logger. +This shields you from missing keys while still surfacing them via the logger. ### 2) Watching for changes @@ -227,7 +345,7 @@ return } ``` -Use this from a goroutine to keep ancillary state in sync with the active language. +Use this from a goroutine to keep ancillary state in sync with the active language. ### 3) Hot-swap language at runtime @@ -235,7 +353,7 @@ Use this from a goroutine to keep ancillary state in sync with the active langua _ = obj.SetLanguage("fr", map[string]string{"hi": "Bonjour"}) ``` -All current waiters are notified; subsequent waits latch onto the fresh channel. +All current waiters are notified; subsequent waits latch onto the fresh channel. ### 4) Custom logger for undefined keys @@ -245,25 +363,26 @@ obj.SetLog(func (s string) { }) ``` -Great for collecting telemetry on missing translations. +Great for collecting telemetry on missing translations. Pass `nil` to reset back to the no-op logger. ## Testing -Run the test suite: +Run the test suite with the race detector: ```bash -go test ./... +go test -race ./... ``` -What’s covered: +What's covered: -* Successful construction and basic lookups. -* Defensive copy semantics for `Words()`. -* `Get` defaulting and miss logging. +* Successful construction and basic lookups. +* Defensive copy semantics for `Words()`. +* `Get` defaulting and miss logging. +* `SetLog(nil)` resets to no-op without panicking. * Validation and error cases in `New`/`SetLanguage`. -* Language switching and current language updates. -* Event handling: `Wait`, `WaitUntilClosed`, and close behavior. -* `Close` clears words and blocks further updates. +* Language switching and current language updates. +* Event handling: `Wait`, `WaitUntilClosed`, and close behavior. +* `Close` clears words and blocks further updates. ## FAQ @@ -273,11 +392,11 @@ subsequent `SetLanguage`, you may still be observing the already-closed channel. channel after closing it; call `Wait` in a loop and treat each return as a single event. **Q: Can I mutate the map returned by `Words()`?** -Yes, it’s a copy. Mutating it won’t affect the internal state. Use `SetLanguage` to replace the internal map. +Yes, it's a copy. Mutating it won't affect the internal state. Use `SetLanguage` to replace the internal map. **Q: What happens after `Close()`?** `Wait` unblocks with `EventClose`, the dictionary is cleared, and `SetLanguage` returns `ErrClosed`. Reads still work -but the dictionary is empty unless you held an external copy. +but the dictionary is empty unless you held an external copy. ## Important Behavior Notes @@ -331,5 +450,5 @@ because `Wait()` never blocks again after the object is closed. ## Limitations * Dictionary-only i18n: no ICU/plural rules, interpolation, or fallback chains—intentionally minimal. -* Blocking waits have no timeout or context cancellation; implement your own goroutine cancellation if needed. -* Language identity equality is string-based; `SetLanguage("en", …)` to `"en"` returns `ErrLangAlreadySet`. +* `Wait()` itself has no timeout parameter; use `WaitChan()` with a `select` and `ctx.Done()` for cancellable waits. +* Language identity equality is string-based; `SetLanguage("en", …)` to `"en"` returns `ErrLangAlreadySet`.