From 5a3a4eb364a43c901fb64aa0ffcd47c615f3ef54 Mon Sep 17 00:00:00 2001 From: Stepan Ananin Date: Tue, 10 Feb 2026 23:35:59 +0300 Subject: [PATCH 1/8] add StaticStack, DynamicStack and SyncStack --- structs/stack.go | 202 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 structs/stack.go diff --git a/structs/stack.go b/structs/stack.go new file mode 100644 index 0000000..362c526 --- /dev/null +++ b/structs/stack.go @@ -0,0 +1,202 @@ +package structs + +import ( + "fmt" + "reflect" + "sync" +) + +// Represents Last-In-First-Out stack. +type Stack[T any] interface { + // Pushes new element on top of the stack. + // Returns false if stack is overflowed (only if it can be overflowed). + Push(v T) bool + // Removes top element from the stack. + // Returns false if stack is empty. + Pop() (T, bool) + // Gets top element from the stack. + // Returns false if stack is empty. + Peek() (T, bool) + // Returns amount of elements in the stack. + Size() int64 + IsEmpty() bool +} + +// Wrapper that makes S thread-Safe. +// It just wraps Stack methods of S using mutex, +// so it's not a some kind of stack on it's own. +// (although it inherently implements Stack interface) +type SyncStack[T any, S Stack[T]] struct { + stack S + mut sync.Mutex +} + +func NewSyncStack[T any, S Stack[T]](s S) *SyncStack[T, S] { + if reflect.ValueOf(s).IsNil() { + panic("can't create SyncStack from nil") + } + return &SyncStack[T, S]{ + stack: s, + } +} + +// Pushes new element on top of S. +// Returns false if S is overflowed (only if it can be overflowed). +func (s *SyncStack[T, S]) Push(v T) bool { + s.mut.Lock() + defer s.mut.Unlock() + return s.stack.Push(v) +} + +// Removes top element from S. +// Returns false if stack is empty. +func (s *SyncStack[T, S]) Pop() (T, bool) { + s.mut.Lock() + defer s.mut.Unlock() + return s.stack.Pop() +} + +// Gets top element from S. +// Returns false if stack is empty. +func (s *SyncStack[T, S]) Peek() (T, bool) { + s.mut.Lock() + defer s.mut.Unlock() + return s.stack.Peek() +} + +// Returns amount of elements in S. +func (s *SyncStack[T, S]) Size() int64 { + s.mut.Lock() + defer s.mut.Unlock() + return s.stack.Size() +} + +func (s *SyncStack[T, S]) IsEmpty() bool { + s.mut.Lock() + defer s.mut.Unlock() + return s.Size() == 0 +} + +// Last-In-First-Out static stack data structure. +type StaticStack[T any] struct { + buffer []T + cap int64 + cursor int64 +} + +// Panics if capacity is <= 0. +func NewStaticStack[T any](capacity int64) *StaticStack[T] { + if capacity <= 0 { + panic(fmt.Sprintf("invalid stack capacity: %d", capacity)) + } + return &StaticStack[T]{ + cap: capacity, + buffer: make([]T, capacity), + cursor: -1, + } +} + +// Pushes new element on top of the stack. +// Returns false if stack is overflowed. +func (s *StaticStack[T]) Push(v T) bool { + if s.cursor >= s.cap-1 { + return false + } + s.cursor++ + s.buffer[s.cursor] = v + return true +} + +// Gets top element from the stack. +// Returns false if stack is empty. +func (s *StaticStack[T]) Peek() (T, bool) { + if s.cursor < 0 { + var zero T + return zero, false + } + return s.buffer[s.cursor], true +} + +// Removes top element from the stack. +// Returns false if stack is empty. +func (s *StaticStack[T]) Pop() (T, bool) { + if s.cursor < 0 { + var zero T + return zero, false + } + s.cursor-- + return s.buffer[s.cursor+1], true +} + +// Returns max stack size. +func (s *StaticStack[T]) Capacity() int64 { + return s.cap +} + +// Returns amount of elements in the stack. +func (s *StaticStack[T]) Size() int64 { + return s.cursor + 1 +} + +func (s *StaticStack[T]) IsEmpty() bool { + return s.cursor == -1 +} + +func (s *StaticStack[T]) IsFull() bool { + return s.Size() == s.cap +} + +// Last-In-First-Out dynamic stack data structure. +type DynamicStack[T any] struct { + buffer []T +} + +// Sets capacity to default if it's <= 0. +func NewDynamicStack[T any](capacity int) *DynamicStack[T] { + if capacity <= 0 { + capacity = 16 + } + return &DynamicStack[T]{ + buffer: make([]T, 0, capacity), + } +} + +// Pushes new element on top of the stack. +// Always returns true. +func (s *DynamicStack[T]) Push(v T) bool { + s.buffer = append(s.buffer, v) + return true +} + +// Gets top element from the stack. +// Returns false if stack is empty. +func (s *DynamicStack[T]) Peek() (T, bool) { + idx := len(s.buffer) - 1 + if idx < 0 { + var zero T + return zero, false + } + return s.buffer[idx], true +} + +// Removes top element from the stack. +// Returns false if stack is empty. +func (s *DynamicStack[T]) Pop() (T, bool) { + if len(s.buffer) == 0 { + var zero T + return zero, false + } + lastIdx := len(s.buffer) - 1 + last := s.buffer[lastIdx] + s.buffer = s.buffer[:lastIdx] + return last, true +} + +// Returns amount of elements in the stack. +func (s *DynamicStack[T]) Size() int64 { + return int64(len(s.buffer)) +} + +func (s *DynamicStack[T]) IsEmpty() bool { + return s.Size() == 0 +} From 79138d0b11e3e07bbe7e380a1cf3da6ea6a56fbd Mon Sep 17 00:00:00 2001 From: Stepan Ananin Date: Fri, 13 Feb 2026 18:35:44 +0300 Subject: [PATCH 2/8] fix dead lock in SyncStack.IsEmpty() --- structs/stack.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/structs/stack.go b/structs/stack.go index 362c526..03f7d2a 100644 --- a/structs/stack.go +++ b/structs/stack.go @@ -72,8 +72,6 @@ func (s *SyncStack[T, S]) Size() int64 { } func (s *SyncStack[T, S]) IsEmpty() bool { - s.mut.Lock() - defer s.mut.Unlock() return s.Size() == 0 } From 97c5e79200e413d73a47388f68aee50babfd5496 Mon Sep 17 00:00:00 2001 From: Stepan Ananin Date: Fri, 13 Feb 2026 18:35:52 +0300 Subject: [PATCH 3/8] add tests for stack --- structs/stack_test.go | 537 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 537 insertions(+) create mode 100644 structs/stack_test.go diff --git a/structs/stack_test.go b/structs/stack_test.go new file mode 100644 index 0000000..b3c8235 --- /dev/null +++ b/structs/stack_test.go @@ -0,0 +1,537 @@ +package structs + +import ( + "sync" + "testing" +) + +func TestNewStaticStack(t *testing.T) { + t.Run("valid capacity", func(t *testing.T) { + s := NewStaticStack[int](5) + if s == nil { + t.Fatal("Stack should not be nil") + } + if s.Capacity() != 5 { + t.Errorf("Expected capacity 5, got %d", s.Capacity()) + } + if s.Size() != 0 { + t.Errorf("Expected initial size 0, got %d", s.Size()) + } + if !s.IsEmpty() { + t.Error("Stack should be empty initially") + } + }) + + t.Run("zero capacity", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("Expected panic for zero capacity") + } + }() + NewStaticStack[string](0) + }) + + t.Run("negative capacity", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("Expected panic for negative capacity") + } + }() + NewStaticStack[int](-1) + }) +} + +func TestStaticStackPush(t *testing.T) { + t.Run("push within capacity", func(t *testing.T) { + s := NewStaticStack[int](3) + + if !s.Push(1) { + t.Error("First push should succeed") + } + if s.Size() != 1 { + t.Errorf("Expected size 1, got %d", s.Size()) + } + + if !s.Push(2) { + t.Error("Second push should succeed") + } + if s.Size() != 2 { + t.Errorf("Expected size 2, got %d", s.Size()) + } + }) + + t.Run("push to full capacity", func(t *testing.T) { + s := NewStaticStack[string](2) + + if !s.Push("first") { + t.Error("First push should succeed") + } + if !s.Push("second") { + t.Error("Second push should succeed") + } + if s.Size() != 2 { + t.Errorf("Expected size 2, got %d", s.Size()) + } + if !s.IsFull() { + t.Error("Stack should be full") + } + }) + + t.Run("push beyond capacity", func(t *testing.T) { + s := NewStaticStack[int](1) + + if !s.Push(42) { + t.Error("First push should succeed") + } + + if s.Push(99) { + t.Error("Push beyond capacity should fail") + } + if s.Size() != 1 { + t.Errorf("Expected size 1, got %d", s.Size()) + } + }) +} + +func TestStaticStackPop(t *testing.T) { + t.Run("pop from empty stack", func(t *testing.T) { + s := NewStaticStack[int](5) + + val, ok := s.Pop() + if ok { + t.Error("Pop from empty stack should return false") + } + if val != 0 { + t.Errorf("Expected zero value, got %d", val) + } + if s.Size() != 0 { + t.Errorf("Expected size 0, got %d", s.Size()) + } + }) + + t.Run("pop single element", func(t *testing.T) { + s := NewStaticStack[string](5) + s.Push("only") + + val, ok := s.Pop() + if !ok { + t.Error("Pop should succeed") + } + if val != "only" { + t.Errorf("Expected 'only', got %s", val) + } + if s.Size() != 0 { + t.Errorf("Expected size 0, got %d", s.Size()) + } + if !s.IsEmpty() { + t.Error("Stack should be empty after pop") + } + }) + + t.Run("pop multiple elements LIFO", func(t *testing.T) { + s := NewStaticStack[int](5) + elements := []int{1, 2, 3} + + for _, elem := range elements { + s.Push(elem) + } + + // Pop in reverse order (LIFO) + for i := len(elements) - 1; i >= 0; i-- { + val, ok := s.Pop() + if !ok { + t.Errorf("Pop %d should succeed", i) + } + if val != elements[i] { + t.Errorf("Pop %d: expected %d, got %d", i, elements[i], val) + } + } + + if s.Size() != 0 { + t.Errorf("Expected final size 0, got %d", s.Size()) + } + }) +} + +func TestStaticStackPeek(t *testing.T) { + t.Run("peek empty stack", func(t *testing.T) { + s := NewStaticStack[string](5) + + val, ok := s.Peek() + if ok { + t.Error("Peek from empty stack should return false") + } + if val != "" { + t.Errorf("Expected zero value, got %s", val) + } + if s.Size() != 0 { + t.Errorf("Expected size 0, got %d", s.Size()) + } + }) + + t.Run("peek non-empty stack", func(t *testing.T) { + s := NewStaticStack[int](5) + s.Push(1) + s.Push(2) + s.Push(3) + + val, ok := s.Peek() + if !ok { + t.Error("Peek should succeed") + } + if val != 3 { + t.Errorf("Expected 3, got %d", val) + } + + // Multiple peeks should return same value + val2, ok2 := s.Peek() + if !ok2 { + t.Error("Second peek should succeed") + } + if val2 != 3 { + t.Errorf("Expected 3 again, got %d", val2) + } + + // Peek should not change size + if s.Size() != 3 { + t.Errorf("Expected size 3, got %d", s.Size()) + } + }) +} + +func TestStaticStackSizeAndEmpty(t *testing.T) { + s := NewStaticStack[int](3) + + if s.Size() != 0 { + t.Errorf("Initial size should be 0, got %d", s.Size()) + } + if !s.IsEmpty() { + t.Error("Initial stack should be empty") + } + if s.IsFull() { + t.Error("Initial stack should not be full") + } + + s.Push(1) + if s.Size() != 1 { + t.Errorf("Size should be 1, got %d", s.Size()) + } + if s.IsEmpty() { + t.Error("Stack should not be empty after push") + } + if s.IsFull() { + t.Error("Stack should not be full with one element") + } + + s.Push(2) + s.Push(3) + if s.Size() != 3 { + t.Errorf("Size should be 3, got %d", s.Size()) + } + if s.IsEmpty() { + t.Error("Stack should not be empty") + } + if !s.IsFull() { + t.Error("Stack should be full") + } +} + +func TestNewDynamicStack(t *testing.T) { + t.Run("positive capacity", func(t *testing.T) { + s := NewDynamicStack[int](10) + if s == nil { + t.Fatal("Stack should not be nil") + } + if s.Size() != 0 { + t.Errorf("Expected initial size 0, got %d", s.Size()) + } + if !s.IsEmpty() { + t.Error("Stack should be empty initially") + } + }) + + t.Run("zero capacity", func(t *testing.T) { + s := NewDynamicStack[string](0) + if s == nil { + t.Fatal("Stack should not be nil") + } + // Should use default capacity of 16 + }) + + t.Run("negative capacity", func(t *testing.T) { + s := NewDynamicStack[int](-5) + if s == nil { + t.Fatal("Stack should not be nil") + } + // Should use default capacity of 16 + }) +} + +func TestDynamicStackPush(t *testing.T) { + t.Run("push single element", func(t *testing.T) { + s := NewDynamicStack[int](0) + + if !s.Push(42) { + t.Error("Push should always succeed") + } + if s.Size() != 1 { + t.Errorf("Expected size 1, got %d", s.Size()) + } + }) + + t.Run("push multiple elements", func(t *testing.T) { + s := NewDynamicStack[string](2) // small initial capacity + + // Push more than initial capacity to test growth + elements := []string{"a", "b", "c", "d", "e"} + for i, elem := range elements { + if !s.Push(elem) { + t.Errorf("Push %d should succeed", i) + } + } + + if s.Size() != int64(len(elements)) { + t.Errorf("Expected size %d, got %d", len(elements), s.Size()) + } + }) +} + +func TestDynamicStackPop(t *testing.T) { + t.Run("pop from empty stack", func(t *testing.T) { + s := NewDynamicStack[int](0) + + val, ok := s.Pop() + if ok { + t.Error("Pop from empty stack should return false") + } + if val != 0 { + t.Errorf("Expected zero value, got %d", val) + } + }) + + t.Run("pop multiple elements LIFO", func(t *testing.T) { + s := NewDynamicStack[int](0) + elements := []int{10, 20, 30, 40, 50} + + for _, elem := range elements { + s.Push(elem) + } + + // Pop in reverse order + for i := len(elements) - 1; i >= 0; i-- { + val, ok := s.Pop() + if !ok { + t.Errorf("Pop %d should succeed", i) + } + if val != elements[i] { + t.Errorf("Pop %d: expected %d, got %d", i, elements[i], val) + } + } + + if s.Size() != 0 { + t.Errorf("Expected final size 0, got %d", s.Size()) + } + if !s.IsEmpty() { + t.Error("Stack should be empty after popping all elements") + } + }) +} + +func TestDynamicStackPeek(t *testing.T) { + t.Run("peek empty stack", func(t *testing.T) { + s := NewDynamicStack[string](0) + + val, ok := s.Peek() + if ok { + t.Error("Peek from empty stack should return false") + } + if val != "" { + t.Errorf("Expected zero value, got %s", val) + } + }) + + t.Run("peek non-empty stack", func(t *testing.T) { + s := NewDynamicStack[int](0) + s.Push(100) + s.Push(200) + + val, ok := s.Peek() + if !ok { + t.Error("Peek should succeed") + } + if val != 200 { + t.Errorf("Expected 200, got %d", val) + } + + // Peek should not change size + if s.Size() != 2 { + t.Errorf("Expected size 2, got %d", s.Size()) + } + }) +} + +func TestNewSyncStack(t *testing.T) { + t.Run("valid stack", func(t *testing.T) { + staticStack := NewStaticStack[int](5) + syncStack := NewSyncStack[int, *StaticStack[int]](staticStack) + + if syncStack == nil { + t.Fatal("SyncStack should not be nil") + } + if syncStack.Size() != 0 { + t.Errorf("Expected initial size 0, got %d", syncStack.Size()) + } + if !syncStack.IsEmpty() { + t.Error("Stack should be empty initially") + } + }) + + t.Run("nil stack", func(t *testing.T) { + defer func() { + if r := recover(); r == nil { + t.Error("Expected panic for nil stack") + } + }() + NewSyncStack[int, *StaticStack[int]](nil) + }) +} + +func TestSyncStackOperations(t *testing.T) { + t.Run("basic operations", func(t *testing.T) { + staticStack := NewStaticStack[string](3) + syncStack := NewSyncStack[string, *StaticStack[string]](staticStack) + + // Push + if !syncStack.Push("first") { + t.Error("Push should succeed") + } + if syncStack.Size() != 1 { + t.Errorf("Expected size 1, got %d", syncStack.Size()) + } + + // Peek + val, ok := syncStack.Peek() + if !ok { + t.Error("Peek should succeed") + } + if val != "first" { + t.Errorf("Expected 'first', got %s", val) + } + + // Push another + if !syncStack.Push("second") { + t.Error("Second push should succeed") + } + + // Pop + val, ok = syncStack.Pop() + if !ok { + t.Error("Pop should succeed") + } + if val != "second" { + t.Errorf("Expected 'second', got %s", val) + } + if syncStack.Size() != 1 { + t.Errorf("Expected size 1, got %d", syncStack.Size()) + } + }) +} + +func TestSyncStackConcurrency(t *testing.T) { + t.Run("concurrent operations", func(t *testing.T) { + staticStack := NewDynamicStack[int](0) + syncStack := NewSyncStack[int, *DynamicStack[int]](staticStack) + + const numOperations = 1000 + var wg sync.WaitGroup + var pushCount, popCount int64 + var pushCountMu, popCountMu sync.Mutex + + // Add some initial elements + for i := range 100 { + syncStack.Push(i) + } + + // Concurrent pushes + wg.Add(1) + go func() { + defer wg.Done() + for i := range numOperations { + if syncStack.Push(i) { + pushCountMu.Lock() + pushCount++ + pushCountMu.Unlock() + } + } + }() + + // Concurrent pops + wg.Add(1) + go func() { + defer wg.Done() + for range numOperations { + if _, ok := syncStack.Pop(); ok { + popCountMu.Lock() + popCount++ + popCountMu.Unlock() + } + } + }() + + wg.Wait() + + pushCountMu.Lock() + actualPushCount := pushCount + pushCountMu.Unlock() + + popCountMu.Lock() + actualPopCount := popCount + popCountMu.Unlock() + + if actualPushCount == 0 { + t.Error("No pushes succeeded") + } + if actualPopCount == 0 { + t.Error("No pops succeeded") + } + + expectedSize := 100 + actualPushCount - actualPopCount + finalSize := syncStack.Size() + if finalSize != expectedSize { + t.Errorf("Expected final size %d, got %d", expectedSize, finalSize) + } + }) + + t.Run("concurrent size checks", func(t *testing.T) { + staticStack := NewDynamicStack[string](0) + syncStack := NewSyncStack[string, *DynamicStack[string]](staticStack) + + const numOperations = 100 + var wg sync.WaitGroup + + // Concurrent pushes + for i := range numOperations { + wg.Add(1) + go func(i int) { + defer wg.Done() + syncStack.Push("test") + }(i) + } + + // Concurrent size checks + for range 10 { + wg.Add(1) + go func() { + defer wg.Done() + for range 10 { + size := syncStack.Size() + if size < 0 || size > numOperations { + t.Errorf("Invalid size: %d", size) + } + } + }() + } + + wg.Wait() + }) +} From 7f62132dcba81aefdf860bd78d4ca8d223453e8a Mon Sep 17 00:00:00 2001 From: Stepan Ananin Date: Fri, 13 Feb 2026 18:38:25 +0300 Subject: [PATCH 4/8] upd README.md and CHANGELOG.md --- CHANGELOG.md | 9 +++++++++ README.md | 13 +++++++++++++ 2 files changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f27eae0..9a43522 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [UNRELEASED] + +### Added + +- Stack - classic LIFO stack in several implementations: + - StaticStack - stack with fixed capacity + - DynamicStack - stack with unlimited capacity that grows dynamicaly + - SyncStack - provides thread-safe wrappers for the methods of Stack interface + ## [1.1.0] - 2026-02-08 ### Added diff --git a/README.md b/README.md index 73093f7..9a68a7b 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,19 @@ queue.Push("item2") item, ok := queue.Pop() ``` +#### Stack + +Classic LIFO stack in several implementations. +```go +stack := structs.NewStaticStack[string](10) + +stack.Push("example 1") +stack.Push("example 2") +stack.Pop() // "example 2", true +stack.Pop() // "example 1", true +stack.Pop() // "", false +``` + #### WorkerPool Concurrent task processing with configurable worker count and graceful shutdown. From cd569668ea759fa2abeb9a7b252f96bd1d9c4e62 Mon Sep 17 00:00:00 2001 From: Stepan Ananin Date: Sun, 15 Feb 2026 18:42:24 +0300 Subject: [PATCH 5/8] add PushBatch(), PopBatch(), ToSlice() methods for Stack --- structs/stack.go | 100 +++++++++++ structs/stack_test.go | 402 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 484 insertions(+), 18 deletions(-) diff --git a/structs/stack.go b/structs/stack.go index 03f7d2a..60903be 100644 --- a/structs/stack.go +++ b/structs/stack.go @@ -4,6 +4,8 @@ import ( "fmt" "reflect" "sync" + + "github.com/abaxoth0/Ain/common" ) // Represents Last-In-First-Out stack. @@ -11,15 +13,22 @@ type Stack[T any] interface { // Pushes new element on top of the stack. // Returns false if stack is overflowed (only if it can be overflowed). Push(v T) bool + // Pushes new elements on top of the stack. + // Returns false if stack is overflowed (only if it can be overflowed). + PushBatch(v ...T) bool // Removes top element from the stack. // Returns false if stack is empty. Pop() (T, bool) + // Removes n top elements from the stack. + // Returns false if stack is empty. + PopBatch(n int64) ([]T, bool) // Gets top element from the stack. // Returns false if stack is empty. Peek() (T, bool) // Returns amount of elements in the stack. Size() int64 IsEmpty() bool + ToSlice() []T } // Wrapper that makes S thread-Safe. @@ -48,6 +57,14 @@ func (s *SyncStack[T, S]) Push(v T) bool { return s.stack.Push(v) } +// Pushes new elements on top of the stack. +// Returns false if stack is overflowed (only if it can be overflowed). +func (s *SyncStack[T, S]) PushBatch(v ...T) bool { + s.mut.Lock() + defer s.mut.Unlock() + return s.stack.PushBatch(v...) +} + // Removes top element from S. // Returns false if stack is empty. func (s *SyncStack[T, S]) Pop() (T, bool) { @@ -56,6 +73,14 @@ func (s *SyncStack[T, S]) Pop() (T, bool) { return s.stack.Pop() } +// Removes n top elements from the stack. +// Returns false if stack is empty. +func (s *SyncStack[T, S]) PopBatch(n int64) ([]T, bool) { + s.mut.Lock() + defer s.mut.Unlock() + return s.stack.PopBatch(n) +} + // Gets top element from S. // Returns false if stack is empty. func (s *SyncStack[T, S]) Peek() (T, bool) { @@ -75,6 +100,12 @@ func (s *SyncStack[T, S]) IsEmpty() bool { return s.Size() == 0 } +func (s *SyncStack[T, S]) ToSlice() []T { + s.mut.Lock() + defer s.mut.Unlock() + return s.stack.ToSlice() +} + // Last-In-First-Out static stack data structure. type StaticStack[T any] struct { buffer []T @@ -105,6 +136,20 @@ func (s *StaticStack[T]) Push(v T) bool { return true } +// Pushes new elements on top of the stack. +// Returns false if stack is overflowed (only if it can be overflowed). +func (s *StaticStack[T]) PushBatch(v ...T) bool { + offset := int64(len(v)) + if s.cursor+offset-1 >= s.cap-1 { + return false + } + for _, elem := range v { + s.cursor++ + s.buffer[s.cursor] = elem + } + return true +} + // Gets top element from the stack. // Returns false if stack is empty. func (s *StaticStack[T]) Peek() (T, bool) { @@ -126,6 +171,27 @@ func (s *StaticStack[T]) Pop() (T, bool) { return s.buffer[s.cursor+1], true } +// Removes n top elements from the stack. +// Returns false if stack is empty. +func (s *StaticStack[T]) PopBatch(n int64) ([]T, bool) { + if n <= 0 { + return make([]T, 0), true + } + if s.cursor < 0 { + return nil, false + } + batchSize := common.Ternary(n > s.Size(), s.Size(), n) + removed := make([]T, 0, batchSize) + for range batchSize { + if s.cursor < 0 { + break + } + removed = append(removed, s.buffer[s.cursor]) + s.cursor-- + } + return removed, true +} + // Returns max stack size. func (s *StaticStack[T]) Capacity() int64 { return s.cap @@ -144,6 +210,12 @@ func (s *StaticStack[T]) IsFull() bool { return s.Size() == s.cap } +func (s *StaticStack[T]) ToSlice() []T { + slice := make([]T, s.Size()) + copy(slice, s.buffer) + return slice +} + // Last-In-First-Out dynamic stack data structure. type DynamicStack[T any] struct { buffer []T @@ -166,6 +238,13 @@ func (s *DynamicStack[T]) Push(v T) bool { return true } +// Pushes new elements on top of the stack. +// Always returns true. +func (s *DynamicStack[T]) PushBatch(v ...T) bool { + s.buffer = append(s.buffer, v...) + return true +} + // Gets top element from the stack. // Returns false if stack is empty. func (s *DynamicStack[T]) Peek() (T, bool) { @@ -190,6 +269,21 @@ func (s *DynamicStack[T]) Pop() (T, bool) { return last, true } +// Removes n top elements from the stack. +// Returns false if stack is empty. +func (s *DynamicStack[T]) PopBatch(n int64) ([]T, bool) { + if n <= 0 { + return make([]T, 0), true + } + if len(s.buffer) == 0 { + return nil, false + } + newSize := max(int64(len(s.buffer)) - n, 0) + removed := s.buffer[newSize:] + s.buffer = s.buffer[:newSize] + return removed, true +} + // Returns amount of elements in the stack. func (s *DynamicStack[T]) Size() int64 { return int64(len(s.buffer)) @@ -198,3 +292,9 @@ func (s *DynamicStack[T]) Size() int64 { func (s *DynamicStack[T]) IsEmpty() bool { return s.Size() == 0 } + +func (s *DynamicStack[T]) ToSlice() []T { + slice := make([]T, s.Size()) + copy(slice, s.buffer) + return slice +} diff --git a/structs/stack_test.go b/structs/stack_test.go index b3c8235..97c9468 100644 --- a/structs/stack_test.go +++ b/structs/stack_test.go @@ -136,7 +136,6 @@ func TestStaticStackPop(t *testing.T) { s.Push(elem) } - // Pop in reverse order (LIFO) for i := len(elements) - 1; i >= 0; i-- { val, ok := s.Pop() if !ok { @@ -183,7 +182,6 @@ func TestStaticStackPeek(t *testing.T) { t.Errorf("Expected 3, got %d", val) } - // Multiple peeks should return same value val2, ok2 := s.Peek() if !ok2 { t.Error("Second peek should succeed") @@ -192,7 +190,6 @@ func TestStaticStackPeek(t *testing.T) { t.Errorf("Expected 3 again, got %d", val2) } - // Peek should not change size if s.Size() != 3 { t.Errorf("Expected size 3, got %d", s.Size()) } @@ -236,6 +233,179 @@ func TestStaticStackSizeAndEmpty(t *testing.T) { } } +func TestStaticStackPushBatch(t *testing.T) { + t.Run("push within capacity", func(t *testing.T) { + s := NewStaticStack[int](5) + + if !s.PushBatch(1, 2, 3) { + t.Error("PushBatch should succeed") + } + if s.Size() != 3 { + t.Errorf("Expected size 3, got %d", s.Size()) + } + + val, _ := s.Peek() + if val != 3 { + t.Errorf("Expected top to be 3, got %d", val) + } + }) + + t.Run("push to full capacity", func(t *testing.T) { + s := NewStaticStack[string](3) + + if !s.PushBatch("a", "b", "c") { + t.Error("PushBatch to full capacity should succeed") + } + if s.Size() != 3 { + t.Errorf("Expected size 3, got %d", s.Size()) + } + if !s.IsFull() { + t.Error("Stack should be full") + } + }) + + t.Run("push beyond capacity", func(t *testing.T) { + s := NewStaticStack[int](2) + + if !s.PushBatch(1, 2) { + t.Error("PushBatch within capacity should succeed") + } + + if s.PushBatch(3, 4) { + t.Error("PushBatch beyond capacity should fail") + } + if s.Size() != 2 { + t.Errorf("Expected size 2, got %d", s.Size()) + } + }) + + t.Run("push empty batch", func(t *testing.T) { + s := NewStaticStack[int](3) + s.Push(1) + + if !s.PushBatch() { + t.Error("PushBatch with no elements should succeed") + } + if s.Size() != 1 { + t.Errorf("Expected size 1, got %d", s.Size()) + } + }) +} + +func TestStaticStackPopBatch(t *testing.T) { + t.Run("pop from empty stack", func(t *testing.T) { + s := NewStaticStack[int](5) + + val, ok := s.PopBatch(3) + if ok { + t.Error("PopBatch from empty stack should return false") + } + if val != nil { + t.Error("Expected nil slice") + } + if s.Size() != 0 { + t.Errorf("Expected size 0, got %d", s.Size()) + } + }) + + t.Run("pop partial batch", func(t *testing.T) { + s := NewStaticStack[string](5) + s.PushBatch("a", "b", "c", "d", "e") + + val, ok := s.PopBatch(2) + if !ok { + t.Error("PopBatch should succeed") + } + if len(val) != 2 { + t.Errorf("Expected 2 elements, got %d", len(val)) + } + if val[0] != "e" || val[1] != "d" { + t.Errorf("Expected [e, d], got %v", val) + } + if s.Size() != 3 { + t.Errorf("Expected size 3, got %d", s.Size()) + } + }) + + t.Run("pop more than available", func(t *testing.T) { + s := NewStaticStack[int](5) + s.PushBatch(1, 2) + + val, ok := s.PopBatch(10) + if !ok { + t.Error("PopBatch should succeed when n > size") + } + if len(val) != 2 { + t.Errorf("Expected 2 elements, got %d", len(val)) + } + if s.Size() != 0 { + t.Errorf("Expected size 0, got %d", s.Size()) + } + }) + + t.Run("pop with n <= 0", func(t *testing.T) { + s := NewStaticStack[int](5) + s.PushBatch(1, 2, 3) + + val, ok := s.PopBatch(0) + if !ok { + t.Error("PopBatch with n=0 should succeed") + } + if len(val) != 0 { + t.Errorf("Expected empty slice, got %d elements", len(val)) + } + if s.Size() != 3 { + t.Errorf("Expected size 3, got %d", s.Size()) + } + + val, ok = s.PopBatch(-5) + if !ok { + t.Error("PopBatch with n<0 should succeed") + } + if len(val) != 0 { + t.Errorf("Expected empty slice, got %d elements", len(val)) + } + }) +} + +func TestStaticStackToSlice(t *testing.T) { + t.Run("empty stack", func(t *testing.T) { + s := NewStaticStack[int](5) + + slice := s.ToSlice() + if len(slice) != 0 { + t.Errorf("Expected empty slice, got %d elements", len(slice)) + } + }) + + t.Run("non-empty stack", func(t *testing.T) { + s := NewStaticStack[string](5) + s.PushBatch("a", "b", "c") + + slice := s.ToSlice() + if len(slice) != 3 { + t.Errorf("Expected 3 elements, got %d", len(slice)) + } + if slice[0] != "a" || slice[1] != "b" || slice[2] != "c" { + t.Errorf("Expected [a, b, c], got %v", slice) + } + }) + + t.Run("slice is independent", func(t *testing.T) { + s := NewStaticStack[int](5) + s.Push(1) + s.Push(2) + + slice := s.ToSlice() + slice[0] = 999 + + val, _ := s.Peek() + if val != 2 { + t.Error("ToSlice should return independent copy") + } + }) +} + func TestNewDynamicStack(t *testing.T) { t.Run("positive capacity", func(t *testing.T) { s := NewDynamicStack[int](10) @@ -255,7 +425,6 @@ func TestNewDynamicStack(t *testing.T) { if s == nil { t.Fatal("Stack should not be nil") } - // Should use default capacity of 16 }) t.Run("negative capacity", func(t *testing.T) { @@ -263,7 +432,6 @@ func TestNewDynamicStack(t *testing.T) { if s == nil { t.Fatal("Stack should not be nil") } - // Should use default capacity of 16 }) } @@ -280,9 +448,8 @@ func TestDynamicStackPush(t *testing.T) { }) t.Run("push multiple elements", func(t *testing.T) { - s := NewDynamicStack[string](2) // small initial capacity + s := NewDynamicStack[string](2) - // Push more than initial capacity to test growth elements := []string{"a", "b", "c", "d", "e"} for i, elem := range elements { if !s.Push(elem) { @@ -317,7 +484,6 @@ func TestDynamicStackPop(t *testing.T) { s.Push(elem) } - // Pop in reverse order for i := len(elements) - 1; i >= 0; i-- { val, ok := s.Pop() if !ok { @@ -363,13 +529,167 @@ func TestDynamicStackPeek(t *testing.T) { t.Errorf("Expected 200, got %d", val) } - // Peek should not change size if s.Size() != 2 { t.Errorf("Expected size 2, got %d", s.Size()) } }) } +func TestDynamicStackPushBatch(t *testing.T) { + t.Run("push single batch", func(t *testing.T) { + s := NewDynamicStack[int](0) + + if !s.PushBatch(1, 2, 3) { + t.Error("PushBatch should succeed") + } + if s.Size() != 3 { + t.Errorf("Expected size 3, got %d", s.Size()) + } + + val, _ := s.Peek() + if val != 3 { + t.Errorf("Expected top to be 3, got %d", val) + } + }) + + t.Run("push multiple batches", func(t *testing.T) { + s := NewDynamicStack[string](2) + + s.PushBatch("a", "b") + s.PushBatch("c", "d", "e") + + if s.Size() != 5 { + t.Errorf("Expected size 5, got %d", s.Size()) + } + }) + + t.Run("push empty batch", func(t *testing.T) { + s := NewDynamicStack[int](3) + s.Push(1) + + if !s.PushBatch() { + t.Error("PushBatch with no elements should succeed") + } + if s.Size() != 1 { + t.Errorf("Expected size 1, got %d", s.Size()) + } + }) +} + +func TestDynamicStackPopBatch(t *testing.T) { + t.Run("pop from empty stack", func(t *testing.T) { + s := NewDynamicStack[int](0) + + val, ok := s.PopBatch(3) + if ok { + t.Error("PopBatch from empty stack should return false") + } + if val != nil { + t.Error("Expected nil slice") + } + if s.Size() != 0 { + t.Errorf("Expected size 0, got %d", s.Size()) + } + }) + + t.Run("pop partial batch", func(t *testing.T) { + s := NewDynamicStack[string](0) + s.PushBatch("a", "b", "c", "d", "e") + + val, ok := s.PopBatch(2) + if !ok { + t.Error("PopBatch should succeed") + } + if len(val) != 2 { + t.Errorf("Expected 2 elements, got %d", len(val)) + } + if val[0] != "d" || val[1] != "e" { + t.Errorf("Expected [d, e], got %v", val) + } + if s.Size() != 3 { + t.Errorf("Expected size 3, got %d", s.Size()) + } + }) + + t.Run("pop more than available", func(t *testing.T) { + s := NewDynamicStack[int](0) + s.PushBatch(1, 2) + + val, ok := s.PopBatch(10) + if !ok { + t.Error("PopBatch should succeed when n > size") + } + if len(val) != 2 { + t.Errorf("Expected 2 elements, got %d", len(val)) + } + if s.Size() != 0 { + t.Errorf("Expected size 0, got %d", s.Size()) + } + }) + + t.Run("pop with n <= 0", func(t *testing.T) { + s := NewDynamicStack[int](0) + s.PushBatch(1, 2, 3) + + val, ok := s.PopBatch(0) + if !ok { + t.Error("PopBatch with n=0 should succeed") + } + if len(val) != 0 { + t.Errorf("Expected empty slice, got %d elements", len(val)) + } + if s.Size() != 3 { + t.Errorf("Expected size 3, got %d", s.Size()) + } + + val, ok = s.PopBatch(-5) + if !ok { + t.Error("PopBatch with n<0 should succeed") + } + if len(val) != 0 { + t.Errorf("Expected empty slice, got %d elements", len(val)) + } + }) +} + +func TestDynamicStackToSlice(t *testing.T) { + t.Run("empty stack", func(t *testing.T) { + s := NewDynamicStack[int](0) + + slice := s.ToSlice() + if len(slice) != 0 { + t.Errorf("Expected empty slice, got %d elements", len(slice)) + } + }) + + t.Run("non-empty stack", func(t *testing.T) { + s := NewDynamicStack[string](0) + s.PushBatch("a", "b", "c") + + slice := s.ToSlice() + if len(slice) != 3 { + t.Errorf("Expected 3 elements, got %d", len(slice)) + } + if slice[0] != "a" || slice[1] != "b" || slice[2] != "c" { + t.Errorf("Expected [a, b, c], got %v", slice) + } + }) + + t.Run("slice is independent", func(t *testing.T) { + s := NewDynamicStack[int](0) + s.Push(1) + s.Push(2) + + slice := s.ToSlice() + slice[0] = 999 + + val, _ := s.Peek() + if val != 2 { + t.Error("ToSlice should return independent copy") + } + }) +} + func TestNewSyncStack(t *testing.T) { t.Run("valid stack", func(t *testing.T) { staticStack := NewStaticStack[int](5) @@ -401,7 +721,6 @@ func TestSyncStackOperations(t *testing.T) { staticStack := NewStaticStack[string](3) syncStack := NewSyncStack[string, *StaticStack[string]](staticStack) - // Push if !syncStack.Push("first") { t.Error("Push should succeed") } @@ -409,7 +728,6 @@ func TestSyncStackOperations(t *testing.T) { t.Errorf("Expected size 1, got %d", syncStack.Size()) } - // Peek val, ok := syncStack.Peek() if !ok { t.Error("Peek should succeed") @@ -418,12 +736,10 @@ func TestSyncStackOperations(t *testing.T) { t.Errorf("Expected 'first', got %s", val) } - // Push another if !syncStack.Push("second") { t.Error("Second push should succeed") } - // Pop val, ok = syncStack.Pop() if !ok { t.Error("Pop should succeed") @@ -437,6 +753,61 @@ func TestSyncStackOperations(t *testing.T) { }) } +func TestSyncStackBatchOperations(t *testing.T) { + t.Run("push batch", func(t *testing.T) { + staticStack := NewStaticStack[int](5) + syncStack := NewSyncStack[int, *StaticStack[int]](staticStack) + + if !syncStack.PushBatch(1, 2, 3) { + t.Error("PushBatch should succeed") + } + if syncStack.Size() != 3 { + t.Errorf("Expected size 3, got %d", syncStack.Size()) + } + + val, _ := syncStack.Peek() + if val != 3 { + t.Errorf("Expected top to be 3, got %d", val) + } + }) + + t.Run("pop batch", func(t *testing.T) { + staticStack := NewStaticStack[string](5) + syncStack := NewSyncStack[string, *StaticStack[string]](staticStack) + + syncStack.PushBatch("a", "b", "c", "d", "e") + + val, ok := syncStack.PopBatch(2) + if !ok { + t.Error("PopBatch should succeed") + } + if len(val) != 2 { + t.Errorf("Expected 2 elements, got %d", len(val)) + } + if val[0] != "e" || val[1] != "d" { + t.Errorf("Expected [e, d], got %v", val) + } + if syncStack.Size() != 3 { + t.Errorf("Expected size 3, got %d", syncStack.Size()) + } + }) + + t.Run("to slice", func(t *testing.T) { + staticStack := NewStaticStack[int](5) + syncStack := NewSyncStack[int, *StaticStack[int]](staticStack) + + syncStack.PushBatch(1, 2, 3) + + slice := syncStack.ToSlice() + if len(slice) != 3 { + t.Errorf("Expected 3 elements, got %d", len(slice)) + } + if slice[0] != 1 || slice[1] != 2 || slice[2] != 3 { + t.Errorf("Expected [1, 2, 3], got %v", slice) + } + }) +} + func TestSyncStackConcurrency(t *testing.T) { t.Run("concurrent operations", func(t *testing.T) { staticStack := NewDynamicStack[int](0) @@ -447,12 +818,10 @@ func TestSyncStackConcurrency(t *testing.T) { var pushCount, popCount int64 var pushCountMu, popCountMu sync.Mutex - // Add some initial elements for i := range 100 { syncStack.Push(i) } - // Concurrent pushes wg.Add(1) go func() { defer wg.Done() @@ -465,7 +834,6 @@ func TestSyncStackConcurrency(t *testing.T) { } }() - // Concurrent pops wg.Add(1) go func() { defer wg.Done() @@ -509,7 +877,6 @@ func TestSyncStackConcurrency(t *testing.T) { const numOperations = 100 var wg sync.WaitGroup - // Concurrent pushes for i := range numOperations { wg.Add(1) go func(i int) { @@ -518,7 +885,6 @@ func TestSyncStackConcurrency(t *testing.T) { }(i) } - // Concurrent size checks for range 10 { wg.Add(1) go func() { From 1e3a036c78baff8b2bf2af42dce83d80198138a5 Mon Sep 17 00:00:00 2001 From: Stepan Ananin Date: Sun, 15 Feb 2026 21:47:52 +0300 Subject: [PATCH 6/8] remove all third-party dependecies; refactor logger --- go.mod | 7 --- go.sum | 15 ------ logger/file-logger.go | 28 +++++----- logger/logger_test.go | 119 ++++++++++++++++++++++++++++++++++++++++++ logger/serializer.go | 42 +++++++++++++++ 5 files changed, 173 insertions(+), 38 deletions(-) create mode 100644 logger/serializer.go diff --git a/go.mod b/go.mod index eb7ffb8..6df91e1 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,3 @@ module github.com/abaxoth0/Ain go 1.23.0 - -require github.com/json-iterator/go v1.1.12 - -require ( - github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect - github.com/modern-go/reflect2 v1.0.2 // indirect -) diff --git a/go.sum b/go.sum index 5ad85e0..e69de29 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +0,0 @@ -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= -github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= -github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= -github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= diff --git a/logger/file-logger.go b/logger/file-logger.go index 7a11217..a54969b 100644 --- a/logger/file-logger.go +++ b/logger/file-logger.go @@ -13,7 +13,6 @@ import ( "time" "github.com/abaxoth0/Ain/structs" - jsoniter "github.com/json-iterator/go" ) const ( @@ -30,9 +29,10 @@ type FileLoggerConfig struct { // File permissions for log files FilePerm os.FileMode //Default: 0644 // Amount of goroutines in fallback WorkerPool (which is used only when main ring buffer is overflowed). - FallbackWorkers int // Default: 5 - FallbackBatchSize int // Default: 500 - StopTimeout time.Duration // Default: 10 sec; To disable set to < 0 + FallbackWorkers int // Default: 5 + FallbackBatchSize int // Default: 500 + StopTimeout time.Duration // Default: 10 sec; To disable set to < 0 + SerializerProducer func() Serializer // nil = default *LoggerConfig } @@ -57,6 +57,9 @@ func (c *FileLoggerConfig) fillEmptySettings() { if c.StopTimeout < 0 { c.StopTimeout = time.Duration((1 << 63) - 1) } + if c.SerializerProducer == nil { + c.SerializerProducer = func() Serializer { return NewJSONSerializer() } + } } // Implements concurrent file-based logging with forwarding capabilities. @@ -91,7 +94,7 @@ func NewFileLogger(config *FileLoggerConfig) (*FileLogger, error) { forwardings: []Logger{}, streamPool: sync.Pool{ New: func() any { - return jsoniter.NewStream(jsoniter.ConfigFastest, nil, 1024) + return config.SerializerProducer() }, }, config: config, @@ -219,29 +222,22 @@ func (l *FileLogger) Stop(strict bool) error { } func (l *FileLogger) handler(entry *LogEntry) { - stream := l.streamPool.Get().(*jsoniter.Stream) + stream := l.streamPool.Get().(Serializer) defer l.streamPool.Put(stream) - stream.Reset(nil) - stream.Error = nil + stream.Reset() - stream.WriteVal(entry) - if stream.Error != nil { + if err :=stream.WriteVal(entry); err != nil { // TODO: // Need to somehow handle failed logs commits, cuz currently they are just loss. // (Push to fallback? Retry queue/buffer?) return } - if stream.Buffered() > 0 { - // Add newline to ensure each log entry is on its own line - stream.WriteRaw("\n") - } - // NOTE: // Logger from built-in "log" package uses mutexes and atomic operations // under the hood, so it's already thread safe. - l.logger.Writer().Write(stream.Buffer()) + l.logger.Writer().Write(append(stream.Buffer(), '\n')) } func (l *FileLogger) log(entry *LogEntry) { diff --git a/logger/logger_test.go b/logger/logger_test.go index 2329a2e..691a219 100644 --- a/logger/logger_test.go +++ b/logger/logger_test.go @@ -699,6 +699,125 @@ func TestHandleCritical(t *testing.T) { }) } +type mockSerializer struct { + resetCalled bool + writeCalled bool + data []byte +} + +func (s *mockSerializer) Reset() { + s.resetCalled = true + s.data = s.data[:0] +} + +func (s *mockSerializer) WriteVal(v any) error { + s.writeCalled = true + s.data = append(s.data, `{"test":"data"}`...) + return nil +} + +func (s *mockSerializer) Buffer() []byte { + return s.data +} + +func TestFileLoggerSerializerDI(t *testing.T) { + t.Run("custom serializer producer is used", func(t *testing.T) { + tmpDir := t.TempDir() + + producerCalled := false + logger, err := NewFileLogger(&FileLoggerConfig{ + Path: tmpDir, + SerializerProducer: func() Serializer { + producerCalled = true + return &mockSerializer{} + }, + }) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + logger.Init() + if err := logger.Start(); err != nil { + t.Fatalf("Expected no error starting logger, got %v", err) + } + + entry := NewLogEntry(InfoLogLevel, "test_source", "test message", "", nil) + logger.Log(&entry) + + time.Sleep(100 * time.Millisecond) + if err := logger.Stop(true); err != nil { + t.Errorf("Failed to stop logger: %v\n", err) + } + + if !producerCalled { + t.Error("Expected serializer producer to be called") + } + }) + + t.Run("default serializer when producer is nil", func(t *testing.T) { + tmpDir := t.TempDir() + + logger, err := NewFileLogger(&FileLoggerConfig{ + Path: tmpDir, + }) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + if logger == nil { + t.Fatal("Expected logger to be non-nil with nil producer") + } + + logger.Init() + if err := logger.Start(); err != nil { + t.Fatalf("Expected no error starting logger, got %v", err) + } + + entry := NewLogEntry(InfoLogLevel, "test_source", "test message", "", nil) + logger.Log(&entry) + + time.Sleep(100 * time.Millisecond) + if err := logger.Stop(true); err != nil { + t.Errorf("Failed to stop logger: %v\n", err) + } + }) + + t.Run("serializer receives reset and write calls", func(t *testing.T) { + tmpDir := t.TempDir() + + serializer := &mockSerializer{} + logger, err := NewFileLogger(&FileLoggerConfig{ + Path: tmpDir, + SerializerProducer: func() Serializer { + return serializer + }, + }) + if err != nil { + t.Fatalf("Expected no error, got %v", err) + } + + logger.Init() + if err := logger.Start(); err != nil { + t.Fatalf("Expected no error starting logger, got %v", err) + } + + entry := NewLogEntry(InfoLogLevel, "test_source", "test message", "", nil) + logger.Log(&entry) + + time.Sleep(100 * time.Millisecond) + if err := logger.Stop(true); err != nil { + t.Errorf("Failed to stop logger: %v\n", err) + } + + if !serializer.resetCalled { + t.Error("Expected serializer Reset() to be called") + } + if !serializer.writeCalled { + t.Error("Expected serializer WriteVal() to be called") + } + }) +} + func TestConcurrentLogging(t *testing.T) { t.Run("concurrent source logging", func(t *testing.T) { mock := &mockLogger{} diff --git a/logger/serializer.go b/logger/serializer.go new file mode 100644 index 0000000..f124ae0 --- /dev/null +++ b/logger/serializer.go @@ -0,0 +1,42 @@ +package logger + +import ( + "bytes" + "encoding/json" +) + +type Serializer interface { + Reset() + WriteVal(v any) error + Buffer() []byte +} + +type JSONSerializer struct { + buffer *bytes.Buffer + encoder *json.Encoder +} + +func NewJSONSerializer() *JSONSerializer { + buf := new(bytes.Buffer) + return &JSONSerializer{ + buffer: buf, + encoder: json.NewEncoder(buf), + } +} + +func (s *JSONSerializer) Reset() { + s.buffer.Reset() +} + +func (s *JSONSerializer) Buffer() []byte { + return s.buffer.Bytes() +} + +func (s *JSONSerializer) WriteVal(v any) error { + if err := s.encoder.Encode(v); err != nil { + return err + } + // json.Encoder.Encode() appends \n at the end, need to trim it + s.buffer.Truncate(s.buffer.Len() - 1) + return nil +} From abdf71eb446d9692f4d35ffda4a218476a876fee Mon Sep 17 00:00:00 2001 From: Stepan Ananin Date: Sun, 15 Feb 2026 22:01:03 +0300 Subject: [PATCH 7/8] upd docs --- CHANGELOG.md | 12 ++++++++++++ SECURITY.md | 8 +------- logger/file-logger.go | 4 ++-- logger/serializer.go | 9 +++++++++ 4 files changed, 24 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f27eae0..a2b7a4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [UNRELEASED] + +### Changed + +- Logger: + - Add DI for FileLogger (FileLoggerConfig.SerializerProducer property): Now FileLogger relies on Serializer interface to encode logs. + This change removes hard-lock on JSON-lines log format. + +### Removed + +- All third-party dependencies + ## [1.1.0] - 2026-02-08 ### Added diff --git a/SECURITY.md b/SECURITY.md index ccdbd38..830dfe2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -48,13 +48,7 @@ This library contains high-performance concurrent data structures. Be aware of: ### Dependencies -Current dependencies are minimal and regularly updated: - -``` -github.com/json-iterator/go v1.1.12 -github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 -github.com/modern-go/reflect2 v1.0.2 -``` +There are no third-party dependencies for this library. ### No User Data Collection diff --git a/logger/file-logger.go b/logger/file-logger.go index a54969b..71c451a 100644 --- a/logger/file-logger.go +++ b/logger/file-logger.go @@ -32,7 +32,7 @@ type FileLoggerConfig struct { FallbackWorkers int // Default: 5 FallbackBatchSize int // Default: 500 StopTimeout time.Duration // Default: 10 sec; To disable set to < 0 - SerializerProducer func() Serializer // nil = default + SerializerProducer func() Serializer // Default: func () Serializer { return NewJSONSerializer() } *LoggerConfig } @@ -227,7 +227,7 @@ func (l *FileLogger) handler(entry *LogEntry) { stream.Reset() - if err :=stream.WriteVal(entry); err != nil { + if err := stream.WriteVal(entry); err != nil { // TODO: // Need to somehow handle failed logs commits, cuz currently they are just loss. // (Push to fallback? Retry queue/buffer?) diff --git a/logger/serializer.go b/logger/serializer.go index f124ae0..1e3e114 100644 --- a/logger/serializer.go +++ b/logger/serializer.go @@ -5,12 +5,21 @@ import ( "encoding/json" ) +// Interface for serializing log entries. +// Implementations must be safe for concurrent use from multiple goroutines +// since a single instance may be retrieved from a pool and used concurrently. type Serializer interface { + // Clears the serializer's internal buffer, preparing it for a new entry. Reset() + // Serializes the given value to the internal buffer. WriteVal(v any) error + // Returns the serialized data. Buffer() []byte } +// Default Serializer implementation using encoding/json. +// It uses a bytes.Buffer internally which is reset and reused via sync.Pool +// to reduce allocations. type JSONSerializer struct { buffer *bytes.Buffer encoder *json.Encoder From 5708bc0c9db888fe61df2ad1c74278c431a9a60a Mon Sep 17 00:00:00 2001 From: Stepan Ananin Date: Sun, 15 Feb 2026 22:14:41 +0300 Subject: [PATCH 8/8] upd CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58a12ab..3f31b4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [UNRELEASED] +## [v1.2.0] - 2026-02-15 ### Added