tales

package module
v0.3.3 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 19, 2026 License: MIT Imports: 26 Imported by: 0

README

kelindar/tales
Go Version PkgGoDev License

Multi-Actor Event Logging in S3

Tales is an append-only event log for Go. It buffers events in memory, stores them as compressed files in S3, and finds them by actor ID. An actor can be a user, account, device, conversation, game object, or anything else represented by a uint32.

  • Actor Queries: Find events for one actor or events shared by several actors.
  • Multiple Writers: Each application instance writes its own files under the same prefix.
  • Explicit Durability: Sync confirms that previously accepted events are stored in S3.
  • Historical Compaction: Compact reduces the number of files needed to query older days.

Use When

  • Events belong to one or more numeric actors.
  • Workloads mostly append events and read actor history.
  • S3 should be the persistent store.
  • Several application instances need to write under the same prefix.

Not For

  • Updating or deleting individual events.
  • Full-text search, joins, arbitrary JSON filters, or transactions.
  • Sharing one writer ID between live processes.
  • Workflows where every Log call must immediately write to S3; use Sync at the required durability boundary.

Quick Start

package main

import (
	"context"
	"log"
	"time"

	"github.com/kelindar/tales"
)

func main() {
	ctx := context.Background()
	logger, err := tales.New("my-bucket", "us-east-1",
		tales.WithPrefix("events"),
	)
	if err != nil {
		log.Fatal(err)
	}

	if err := logger.Log("Player joined", 12345); err != nil {
		log.Fatal(err)
	}
	if err := logger.Log("Player attacked monster", 12345, 67890); err != nil {
		log.Fatal(err)
	}
	if err := logger.Sync(ctx); err != nil {
		log.Fatal(err)
	}

	from := time.Now().Add(-time.Hour)
	to := time.Now()
	for event, err := range logger.Scan(ctx, from, to, 12345) {
		if err != nil {
			log.Fatal(err)
		}
		log.Println(event.Text())
	}

	if err := logger.Close(); err != nil {
		log.Fatal(err)
	}
}

Log returns after the event has been accepted into memory. Call Sync when it must be committed to S3. A failed Close leaves the service open so Sync or Close can be retried.

Actor arguments to Scan and Page are combined with AND. Both time bounds are inclusive. Results ascend when the first bound is earlier and descend when it is later. Event views are read-only and may refer to downloaded data; call Clone before retaining or modifying one independently.

Use Page for bounded pagination:

cursor := tales.Zero
for {
	events, next, err := logger.Page(ctx, to, from, cursor, 50, 12345)
	if err != nil {
		log.Fatal(err)
	}
	for _, event := range events { // Newest first because to > from.
		log.Println(event.Text())
	}
	if next == tales.Zero {
		break
	}
	cursor = next
}

Cursor is an opaque URL-safe string. Store it directly in a URL with cursor.String() and restore it with tales.Cursor(value). Page validates malformed cursors. Reuse a cursor only with the same time bounds and actors.

Each service owns one writer ID. Tales creates one automatically, or WithWriterID("game-server-1") can derive a stable ID from a deployment name. Only one live process may use a given writer ID.

Historical compaction

Compaction is optional maintenance for older data. Call it from an external scheduled job, with one compactor running per prefix. The earliest eligible day is two UTC days ago.

day := time.Now().UTC().AddDate(0, 0, -2)
if err := logger.Compact(context.Background(), day); err != nil {
	log.Fatal(err)
}

Compaction is safe to retry. Queries continue using writer files until compacted metadata is committed.

Storage layout

Tales creates these keys below the configured prefix:

YYYY-MM-DD/writers/<writer>/manifest.json
YYYY-MM-DD/writers/<writer>/<sequence>.log
YYYY-MM-DD/compact/index.bin
YYYY-MM-DD/compact/data.log
YYYY-MM-DD/compact/metadata.json

Chunk sequences are zero-based and use 20 decimal digits. This format has no legacy decoder or migration path.

Installation

go get github.com/kelindar/tales

License

Tales is released under the MIT License.

Documentation

Index

Examples

Constants

This section is empty.

Variables

This section is empty.

Functions

This section is empty.

Types

type Compactor added in v0.2.0

type Compactor interface {
	Compact(context.Context, time.Time) error
}

Compactor compacts an eligible historical UTC day.

type Cursor added in v0.3.0

type Cursor string

Cursor is an opaque URL-safe position in Tales' deterministic event order. Reuse it with the same time range and actors.

const Zero Cursor = ""

Zero starts paging at the first event in the requested direction.

func (Cursor) String added in v0.3.0

func (c Cursor) String() string

String returns the unpadded URL-safe cursor string.

type Event added in v0.2.0

type Event = codec.Event

Event is a validated log event. Its projections are zero-copy and read-only; use Clone before retaining or modifying their backing data.

type Logger added in v0.0.2

type Logger interface {
	Log(text string, actors ...uint32) error
}

Logger accepts events into the service's in-memory writer state.

type Manager added in v0.0.2

type Manager interface {
	Logger
	Querier
	Syncer
	Close() error
}

Manager is the primary service contract.

type Option

type Option func(*config)

Option configures the Service. All options are optional and may be provided to New when constructing a logger.

func WithBackblaze

func WithBackblaze() Option

WithBackblaze sets the S3 service to Backblaze B2.

func WithBuffer

func WithBuffer(size int) Option

WithBuffer sets the maximum number of entries kept in memory.

func WithClient

func WithClient(fn func(s3.Config) (s3.Client, error)) Option

WithClient allows overriding the S3 client creation function.

func WithInterval

func WithInterval(d time.Duration) Option

WithInterval sets the interval at which in-memory chunks are flushed.

func WithKey

func WithKey(key *aws.SigningKey) Option

WithKey sets the signing key to use for the S3 client.

func WithPrefix

func WithPrefix(prefix string) Option

WithPrefix sets the S3 key prefix to use when storing objects.

func WithWriterID added in v0.2.0

func WithWriterID(id string) Option

WithWriterID hashes a stable name into the service's 16-character writer ID.

type Querier added in v0.0.2

type Querier interface {
	Page(context.Context, time.Time, time.Time, Cursor, int, ...uint32) ([]Event, Cursor, error)
	Scan(context.Context, time.Time, time.Time, ...uint32) iter.Seq2[Event, error]
}

Querier reads events in deterministic order.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service is a distributed S3-backed event log owned by one writer ID.

func New

func New(bucket, region string, opts ...Option) (*Service, error)

New opens a service for the given S3 bucket and region.

func (*Service) Close

func (l *Service) Close() error

Close syncs pending events and releases the service after a successful flush.

func (*Service) Compact added in v0.2.0

func (l *Service) Compact(ctx context.Context, value time.Time) error

Compact commits an immutable merged index for an eligible historical UTC day.

func (*Service) Log

func (l *Service) Log(text string, actors ...uint32) error

Log accepts an event into local memory for every supplied actor.

func (*Service) Page added in v0.3.0

func (l *Service) Page(ctx context.Context, from, to time.Time, cursor Cursor, limit int, actors ...uint32) ([]Event, Cursor, error)

Page returns at most limit matching events from one inclusive bound toward the other, ascending when from <= to and descending otherwise. The cursor is exclusive; an empty next cursor ends iteration.

Example
var log *Service
ctx := context.Background()
start, now := time.Now().Add(-time.Hour), time.Now()
actors := []uint32{42}
var cursor Cursor

for {
	events, next, err := log.Page(ctx, now, start, cursor, 50, actors...)
	if err != nil {
		return
	}
	for _, event := range events { // Newest first: now toward start.
		_ = event.Bytes()
	}
	if next == Zero {
		break
	}
	cursor = next // Reuse with the same bounds and actors.
}

func (*Service) Scan added in v0.3.0

func (l *Service) Scan(ctx context.Context, from, to time.Time, actors ...uint32) iter.Seq2[Event, error]

Scan yields events containing every actor from one inclusive bound toward the other, ascending when from <= to and descending otherwise.

func (*Service) Sync added in v0.2.0

func (l *Service) Sync(ctx context.Context) error

Sync makes every previously accepted event durable.

type Syncer added in v0.2.0

type Syncer interface {
	Sync(context.Context) error
}

Syncer makes all previously accepted events durable.

Directories

Path Synopsis
cmd
bench command
example command
internal
s3

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL