kelindar/column

repository·main·Indexed 23 days ago

https://github.com/kelindar/column

A high-performance, in-memory columnar storage engine written in Go. It features bitmap indexing, SIMD-optimized operations, and support for fast querying, updates, and iterations. Key capabilities include O(1) lookups via primary keys, atomic updates with custom merging strategies, snapshotting for persistence, and TTL-based row expiration.

Tokens
12.3K
Snippets
21
Records
76
Agent score
81%

What's inside kelindar/column

  1. Create and use Bitmap Indexes

    main

    For frequently used queries, you can create an index using CreateIndex(). An index is defined by a column name and a predicate function. Once created, you can query the index using With(indexName), which is significantly faster (often 10-100x) because it uses bitmap indexing and logical operations instead of full scans.

    Index Operations:

    • Union(otherIndex): Performs a logical OR (merges results).
    • Without(otherIndex): Performs a logical AND NOT (difference).
    • With(index): Selects rows matching the index.
    // Create the index "rogue" in advance
    out.CreateIndex("rogue", "class", func(v interface{}) bool {
    	return v == "rogue"
    })
    
    // This returns the same result as the query before, but much faster
    players.Query(func(txn *column.Txn) error {
    	count := txn.With("rogue").Count()
    	return nil
    })
  2. Manage transactions: Commit and Rollback

    main

    All batch queries must run within a transaction via the Query method. The transaction lifecycle is tied to the return value of the provided callback function:

    • Commit: If the function returns nil, the transaction is automatically committed and changes are applied.
    • Rollback: If the function returns an error, the transaction is automatically rolled back and no changes are applied.
    // Successful commit
    players.Query(func(txn *column.Txn) error {
    	balance := txn.Float64("balance")
    	txn.Range(func(i uint32) {
    		balance.Set(10.0)
    	})
    	return nil // Commit happens here
    })
    
    // Automatic rollback on error
    players.Query(func(txn *column.Txn) error {
    	balance := txn.Float64("balance")
    	txn.Range(func(i uint32) {
    		balance.Set(10.0)
    	})
    	return fmt.Errorf("bug") // Rollback happens here
    })
  3. Store complex structures using Binary Records

    main

    You can store complex Go structs in a single column by using column.ForRecord(). The struct must implement the encoding.BinaryMarshaler and encoding.BinaryUnmarshaler interfaces.

    • Creation: Use players.CreateColumn("col_name", column.ForRecord(func() *MyStruct { return new(MyStruct) })).
    • Writing: Use r.SetRecord("col_name", &MyStruct{...}).
    • Reading: Use r.Record("col_name") to retrieve the struct.
    type Location struct {
    	X float64 `json:"x"`
    	Y float64 `json:"y"`
    }
    
    func (l Location) MarshalBinary() ([]byte, error) { return json.Marshal(l) }
    func (l *Location) UnmarshalBinary(b []byte) error { return json.Unmarshal(b, l) }
    
    // Create column
    players.CreateColumn("location", column.ForRecord(func() *Location {
    	return new(Location)
    }))
    
    // Insert
    players.Insert(func(r column.Row) error {
    	r.SetRecord("location", &Location{X: 1, Y: 2})
    	return nil
    })
    
    // Read
    players.QueryAt(idx, func(r column.Row) error {
    	location, ok := r.Record("location")
    	return nil
    })
  4. Stream changes via Change Data Capture (CDC)

    main

    The library supports streaming transaction commits via a commit.Logger implementation. This is useful for implementing CDC listeners or replicating data to other systems like Kafka.

    • Implementation: Provide a commit.Logger (e.g., commit.Channel) in column.Options when creating a collection.
    • Replication: You can use the Replay(change) method on a replica collection to apply commits received from a primary collection's change stream, ensuring consistent synchronization.
    // Setup streaming via a channel
    writer  := make(commit.Channel, 1024)
    players := column.NewCollection(column.Options{
    	Writer: &writer,
    })
    
    // Consume changes in a goroutine
    go func(){
    	for commit := range writer {
    		fmt.Printf("commit %v\n", commit.ID)
    	}
    }()
    
    // Replicating from primary to replica
    // primary uses 'writer', replica is empty
    go func() {
    	for change := range writer {
    		replica.Replay(change)
    	}
    }()
  5. Iterate over query results with Range()

    main

    To iterate over the rows returned by a query, use the Range() method within a transaction.

    Crucial Step: Before calling Range(), you must load column readers for the data you intend to access using methods like txn.String(columnName), txn.Int64(columnName), etc. These readers prepare the necessary buffers for efficient lookup during iteration.

    players.Query(func(txn *column.Txn) error {
    	names := txn.String("name") // Create a column reader
    
    	return txn.With("rogue").Range(func(i uint32) {
    		name, _ := names.Get()
    		println("rogue name", name)
    	})
    })
  6. Implement a custom merging strategy for atomic updates

    main

    You can use a custom merging function to handle concurrent updates to the same column atomically. When a transaction is committed, the specified merging function is used to reconcile the existing value with the new update.

    This pattern is useful for complex data types, such as JSON-encoded objects containing multiple fields (e.g., position and velocity), where you want to update specific parts of the object without overwriting the entire state. By deferring the merge to the transaction commit phase, the system allows multiple concurrent transactions to prepare updates for the same key, ensuring consistency through the merge logic.

  7. Set and extend TTL for expiring values

    main

    The library automatically manages an expire column for each collection to handle automatic row deletion.

    • Setting TTL: Use r.SetTTL(duration) during an Insert to define how long a row should live.
    • Extending TTL: Since expire is a standard column, you can use txn.TTL() to get a TTL accessor and call ttl.Extend(duration) within a transaction to add more time to existing rows.
    // Set TTL on insert
    players.Insert(func(r column.Row) error {
    	r.SetString("name", "Merlin")
    	r.SetString("class", "mage")
    	r.SetTTL(5 * time.Second) // time-to-live of 5 seconds
    	return nil
    })
    
    // Extend TTL in a query
    players.Query(func(txn *column.Txn) error {
    	ttl := txn.TTL()
    	return txn.Range(func(i uint32) error {
    		ttl.Extend(1 * time.Hour) // Add some time
    		return nil
    	})
    })
  8. Create a Collection and define Columns

    main

    To store data, you must first create a Collection using NewCollection(). You then define its schema by specifying columns with types like ForString(), ForFloat64(), or ForInt16(). Alternatively, you can use CreateColumnsOf() to automatically infer the schema from an object.

    Example of manual schema definition:

    // Create a new collection with some columns
    players := column.NewCollection()
    players.CreateColumn("name", column.ForString())
    players.CreateColumn("class", column.ForString())
    players.CreateColumn("balance", column.ForFloat64())
    players.CreateColumn("age", column.ForInt16())
  9. Update values in a collection

    main

    To update values, use the Range() method within a transaction and call Set() or Add() on the column accessor. Updates are atomic and only applied when the transaction is committed.

    For numerical values, you can use the Merge() method to perform atomic increments or decrements. This ensures that indexes are updated and predicates are re-evaluated with the most up-to-date values.

    // Standard update using Set()
    players.Query(func(txn *column.Txn) error {
    	balance := txn.Float64("balance")
    	age     := txn.Int64("age")
    
    	return txn.With("rogue").Range(func(i uint32) {
    		balance.Set(10.0) // Update the "balance" to 10.0
    		age.Set(50)       // Update the "age" to 50
    	})
    })
    
    // Atomic increment using Merge()
    players.Query(func(txn *column.Txn) error {
    	balance := txn.Float64("balance")
    
    	return txn.With("rogue").Range(func(i uint32) {
    		balance.Merge(500.0) // Increment the "balance" by 500
    	})
    })
  10. Use Primary Keys for fast lookups

    main

    To access specific rows without knowing their internal offset, you can define a primary key using column.ForKey().

    1. Setup: Create a column with players.CreateColumn("name", column.ForKey()).
    2. Insert: Use players.InsertKey(key, callback) to insert a row associated with a specific key.
    3. Query: Use players.QueryKey(key, callback) to perform a direct lookup.

    Note: Primary key lookups involve an internal hash table lookup and have more overhead than direct offset access.

    players := column.NewCollection()
    players.CreateColumn("name", column.ForKey())     // Create a "name" as a primary-key
    players.CreateColumn("class", column.ForString())
    
    // Insert a player with "merlin" as its primary key
    players.InsertKey("merlin", func(r column.Row) error {
    	r.SetString("class", "mage")
    	return nil
    })
    
    // Query merlin's class directly
    players.QueryKey("merlin", func(r column.Row) error {
    	class, _ := r.String("class")
    	return nil
    })
  11. Take and restore snapshots

    main

    Collections can be persisted to a binary format using Snapshot() and Restore() methods.

    • Snapshot: Call players.Snapshot(io.Writer) to save the current state. This can be done while transactions are running.
    • Restore: Call players.Restore(io.Reader) to load a snapshot.

    Important: The collection and its schema must be initialized before calling Restore(), as snapshots do not contain schema information.

  12. Basic usage pattern for Columnar Store

    main

    The standard workflow for using the column package involves four main steps:

    1. Create a collection: Initialize a new data collection.
    2. Load data: Populate the collection with initial data.
    3. Insert indexes: Define indexes on specific columns to enable efficient querying.
    4. Query and Iterate: Execute a query against the collection and iterate over the resulting set.

    This pattern allows for efficient columnar storage with bitmap indexing for fast lookups.