# Project: jormun-db # Generated: Sun Feb 15 11:44:33 AM EST 2026 ================================================================================ ================================================================================ FILE: ./ARCHITECTURE.md ================================================================================ ## JormunDB Architecture This document explains the internal architecture of JormunDB, including design decisions, storage formats, and the arena-per-request memory management pattern. ## Table of Contents - [Overview](#overview) - [Why Odin?](#why-odin) - [Memory Management](#memory-management) - [Storage Format](#storage-format) - [Module Structure](#module-structure) - [Request Flow](#request-flow) - [Concurrency Model](#concurrency-model) ## Overview JormunDB is a DynamoDB-compatible database server that speaks the DynamoDB wire protocol. It uses RocksDB for persistent storage and is written in Odin for elegant memory management. ### Key Design Goals 1. **Zero allocation ceremony** - No explicit `defer free()` or error handling for every allocation 2. **Binary storage** - Efficient TLV encoding instead of JSON 3. **API compatibility** - Drop-in replacement for DynamoDB Local 4. **Performance** - RocksDB-backed with efficient key encoding ## Why Odin? The original implementation in Zig suffered from explicit allocator threading: ```zig // Zig version - explicit allocator everywhere fn handleRequest(allocator: std.mem.Allocator, request: []const u8) !Response { const parsed = try parseJson(allocator, request); defer parsed.deinit(allocator); const item = try storage.getItem(allocator, parsed.table_name, parsed.key); defer if (item) |i| freeItem(allocator, i); const response = try serializeResponse(allocator, item); defer allocator.free(response); return response; // Wait, we deferred the free! } ``` Odin's context allocator system eliminates this: ```odin // Odin version - implicit context allocator handle_request :: proc(request: []byte) -> Response { // All allocations use context.allocator automatically parsed := parse_json(request) item := storage_get_item(parsed.table_name, parsed.key) response := serialize_response(item) return response // Everything freed when arena is destroyed } ``` ## Memory Management JormunDB uses a two-allocator strategy: ### 1. Arena Allocator (Request-Scoped) Every HTTP request gets its own arena: ```odin handle_connection :: proc(conn: net.TCP_Socket) { // Create arena for this request (4MB) arena: mem.Arena mem.arena_init(&arena, make([]byte, mem.Megabyte * 4)) defer mem.arena_destroy(&arena) // Set context allocator context.allocator = mem.arena_allocator(&arena) // All downstream code uses context.allocator request := parse_http_request(conn) // uses arena response := handle_request(request) // uses arena send_response(conn, response) // uses arena // Arena is freed here - everything cleaned up automatically } ``` **Benefits:** - No individual `free()` calls needed - No `errdefer` cleanup - No use-after-free bugs - No memory leaks from forgotten frees - Predictable performance (no GC pauses) ### 2. Default Allocator (Long-Lived Data) The default allocator (typically `context.allocator` at program start) is used for: - Table metadata - Table locks (sync.RW_Mutex) - Engine state - Items returned from storage layer (copied to request arena when needed) ## Storage Format ### Binary Keys (Varint-Prefixed Segments) All keys use varint length prefixes for space efficiency: ``` Meta key: [0x01][len][table_name] Data key: [0x02][len][table_name][len][pk_value][len][sk_value]? GSI key: [0x03][len][table_name][len][index_name][len][gsi_pk][len][gsi_sk]? LSI key: [0x04][len][table_name][len][index_name][len][pk][len][lsi_sk] ``` **Example Data Key:** ``` Table: "Users" PK: "user:123" SK: "profile" Encoded: [0x02] // Entity type (Data) [0x05] // Table name length (5) Users // Table name bytes [0x08] // PK length (8) user:123 // PK bytes [0x07] // SK length (7) profile // SK bytes ``` ### Item Encoding (TLV Format) Items use Tag-Length-Value encoding for space efficiency: ``` Format: [attr_count:varint] [name_len:varint][name:bytes][type_tag:u8][value_len:varint][value:bytes]... Type Tags: String = 0x01 Number = 0x02 Binary = 0x03 Bool = 0x04 Null = 0x05 SS = 0x10 NS = 0x11 BS = 0x12 List = 0x20 Map = 0x21 ``` **Example Item:** ```json { "id": {"S": "user123"}, "age": {"N": "30"} } ``` Encoded as: ``` [0x02] // 2 attributes [0x02] // name length (2) id // name bytes [0x01] // type tag (String) [0x07] // value length (7) user123 // value bytes [0x03] // name length (3) age // name bytes [0x02] // type tag (Number) [0x02] // value length (2) 30 // value bytes (stored as string) ``` ## Module Structure ``` jormundb/ ├── main.odin # Entry point, HTTP server ├── rocksdb/ # RocksDB C FFI bindings │ └── rocksdb.odin # db_open, db_put, db_get, etc. ├── dynamodb/ # DynamoDB protocol implementation │ ├── types.odin # Core types (Attribute_Value, Item, Key, etc.) │ ├── json.odin # DynamoDB JSON parsing/serialization │ ├── storage.odin # Storage engine (CRUD, scan, query) │ └── handler.odin # HTTP request handlers ├── key_codec/ # Binary key encoding │ └── key_codec.odin # build_data_key, decode_data_key, etc. └── item_codec/ # Binary TLV item encoding └── item_codec.odin # encode, decode ``` ## Request Flow ``` 1. HTTP POST / arrives ↓ 2. Create arena allocator (4MB) Set context.allocator = arena_allocator ↓ 3. Parse HTTP headers Extract X-Amz-Target → Operation ↓ 4. Parse JSON body Convert DynamoDB JSON → internal types ↓ 5. Route to handler (e.g., handle_put_item) ↓ 6. Storage engine operation - Build binary key - Encode item to TLV - RocksDB put/get/delete ↓ 7. Build response - Serialize item to DynamoDB JSON - Format HTTP response ↓ 8. Send response ↓ 9. Destroy arena All request memory freed automatically ``` ## Concurrency Model ### Table-Level RW Locks Each table has a reader-writer lock: ```odin Storage_Engine :: struct { db: rocksdb.DB, table_locks: map[string]^sync.RW_Mutex, table_locks_mutex: sync.Mutex, } ``` **Read Operations** (GetItem, Query, Scan): - Acquire shared lock - Multiple readers can run concurrently - Writers are blocked **Write Operations** (PutItem, DeleteItem, UpdateItem): - Acquire exclusive lock - Only one writer at a time - All readers are blocked ### Thread Safety - RocksDB handles are thread-safe (column family-based) - Table metadata is protected by locks - Request arenas are thread-local (no sharing) ## Error Handling Odin uses explicit error returns via `or_return`: ```odin // Odin error handling parse_json :: proc(data: []byte) -> (Item, bool) { parsed := json.parse(data) or_return item := json_to_item(parsed) or_return return item, true } // Usage item := parse_json(request.body) or_else { return error_response(.ValidationException, "Invalid JSON") } ``` No exceptions, no panic-recover patterns. Every error path is explicit. ## DynamoDB Wire Protocol ### Request Format ``` POST / HTTP/1.1 X-Amz-Target: DynamoDB_20120810.PutItem Content-Type: application/x-amz-json-1.0 { "TableName": "Users", "Item": { "id": {"S": "user123"}, "name": {"S": "Alice"} } } ``` ### Response Format ``` HTTP/1.1 200 OK Content-Type: application/x-amz-json-1.0 x-amzn-RequestId: local-request-id {} ``` ### Error Format ```json { "__type": "com.amazonaws.dynamodb.v20120810#ResourceNotFoundException", "message": "Table not found" } ``` ## Performance Characteristics ### Time Complexity | Operation | Complexity | Notes | |-----------|-----------|-------| | PutItem | O(log n) | RocksDB LSM tree insert | | GetItem | O(log n) | RocksDB point lookup | | DeleteItem | O(log n) | RocksDB deletion | | Query | O(log n + m) | n = items in table, m = result set | | Scan | O(n) | Full table scan | ### Space Complexity - Binary keys: ~20-100 bytes (vs 50-200 bytes JSON) - Binary items: ~30% smaller than JSON - Varint encoding saves space on small integers ### Benchmarks (Expected) Based on Zig version performance: ``` Operation Throughput Latency (p50) PutItem ~5,000/sec ~0.2ms GetItem ~7,000/sec ~0.14ms Query (1 item) ~8,000/sec ~0.12ms Scan (1000 items) ~20/sec ~50ms ``` ## Future Enhancements ### Planned Features 1. **UpdateExpression** - SET/REMOVE/ADD/DELETE operations 2. **FilterExpression** - Post-query filtering 3. **ProjectionExpression** - Return subset of attributes 4. **Global Secondary Indexes** - Query by non-key attributes 5. **Local Secondary Indexes** - Alternate sort keys 6. **BatchWriteItem** - Batch mutations 7. **BatchGetItem** - Batch reads 8. **Transactions** - ACID multi-item operations ### Optimization Opportunities 1. **Connection pooling** - Reuse HTTP connections 2. **Bloom filters** - Faster negative lookups 3. **Compression** - LZ4/Zstd on large items 4. **Caching layer** - Hot item cache 5. **Parallel scan** - Segment-based scanning ## Debugging ### Enable Verbose Logging ```bash make run VERBOSE=1 ``` ### Inspect RocksDB ```bash # Use ldb tool to inspect database ldb --db=./data scan ldb --db=./data get ``` ### Memory Profiling Odin's tracking allocator can detect leaks: ```odin when ODIN_DEBUG { track: mem.Tracking_Allocator mem.tracking_allocator_init(&track, context.allocator) context.allocator = mem.tracking_allocator(&track) defer { for _, leak in track.allocation_map { fmt.printfln("Leaked %d bytes at %p", leak.size, leak.location) } } } ``` ## Migration from Zig Version The Zig version (ZynamoDB) used the same binary storage format, so existing RocksDB databases can be read by JormunDB without migration. ### Compatibility - ✅ Binary key format (byte-compatible) - ✅ Binary item format (byte-compatible) - ✅ Table metadata (JSON, compatible) - ✅ HTTP wire protocol (identical) ### Breaking Changes None - JormunDB can open ZynamoDB databases directly. --- ## Contributing When contributing to JormunDB: 1. **Use the context allocator** - All request-scoped allocations should use `context.allocator` 2. **Avoid manual frees** - Let the arena handle it 3. **Long-lived data** - Use the default allocator explicitly 4. **Test thoroughly** - Run `make test` before committing 5. **Format code** - Run `make fmt` before committing ## References - [Odin Language](https://odin-lang.org/) - [RocksDB Wiki](https://github.com/facebook/rocksdb/wiki) - [DynamoDB API Reference](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/) - [Varint Encoding](https://developers.google.com/protocol-buffers/docs/encoding#varints) ================================================================================ FILE: ./dynamodb/types.odin ================================================================================ package dynamodb import "core:fmt" import "core:strings" // DynamoDB AttributeValue - the core data type Attribute_Value :: union { String, // S Number, // N (stored as string) Binary, // B (base64) Bool, // BOOL Null, // NULL String_Set, // SS Number_Set, // NS Binary_Set, // BS List, // L Map, // M } String :: distinct string Number :: distinct string Binary :: distinct string Bool :: distinct bool Null :: distinct bool String_Set :: distinct []string Number_Set :: distinct []string Binary_Set :: distinct []string List :: distinct []Attribute_Value Map :: distinct map[string]Attribute_Value // Item is a map of attribute names to values Item :: map[string]Attribute_Value // Key represents a DynamoDB key (partition key + optional sort key) Key :: struct { pk: Attribute_Value, sk: Maybe(Attribute_Value), } // Free a key key_destroy :: proc(key: ^Key) { attr_value_destroy(&key.pk) if sk, ok := key.sk.?; ok { sk_copy := sk attr_value_destroy(&sk_copy) } } // Extract key from item based on key schema key_from_item :: proc(item: Item, key_schema: []Key_Schema_Element) -> (Key, bool) { pk_value: Attribute_Value sk_value: Maybe(Attribute_Value) for schema_elem in key_schema { attr, ok := item[schema_elem.attribute_name] if !ok { return {}, false } // Validate that key is a scalar type (S, N, or B) #partial switch _ in attr { case String, Number, Binary: // Valid key type case: return {}, false } // Deep copy the attribute value copied := attr_value_deep_copy(attr) switch schema_elem.key_type { case .HASH: pk_value = copied case .RANGE: sk_value = copied } } return Key{pk = pk_value, sk = sk_value}, true } // Convert key to item key_to_item :: proc(key: Key, key_schema: []Key_Schema_Element) -> Item { item := make(Item) for schema_elem in key_schema { attr_value: Attribute_Value switch schema_elem.key_type { case .HASH: attr_value = key.pk case .RANGE: if sk, ok := key.sk.?; ok { attr_value = sk } else { continue } } item[schema_elem.attribute_name] = attr_value_deep_copy(attr_value) } return item } // Extract raw byte values from key Key_Values :: struct { pk: []byte, sk: Maybe([]byte), } key_get_values :: proc(key: ^Key) -> (Key_Values, bool) { pk_bytes: []byte switch v in key.pk { case String: pk_bytes = transmute([]byte)string(v) case Number: pk_bytes = transmute([]byte)string(v) case Binary: pk_bytes = transmute([]byte)string(v) case: return {}, false } sk_bytes: Maybe([]byte) if sk, ok := key.sk.?; ok { switch v in sk { case String: sk_bytes = transmute([]byte)string(v) case Number: sk_bytes = transmute([]byte)string(v) case Binary: sk_bytes = transmute([]byte)string(v) case: return {}, false } } return Key_Values{pk = pk_bytes, sk = sk_bytes}, true } // Key type Key_Type :: enum { HASH, RANGE, } key_type_to_string :: proc(kt: Key_Type) -> string { switch kt { case .HASH: return "HASH" case .RANGE: return "RANGE" } return "HASH" } key_type_from_string :: proc(s: string) -> (Key_Type, bool) { switch s { case "HASH": return .HASH, true case "RANGE": return .RANGE, true } return .HASH, false } // Scalar attribute type Scalar_Attribute_Type :: enum { S, // String N, // Number B, // Binary } scalar_type_to_string :: proc(t: Scalar_Attribute_Type) -> string { switch t { case .S: return "S" case .N: return "N" case .B: return "B" } return "S" } scalar_type_from_string :: proc(s: string) -> (Scalar_Attribute_Type, bool) { switch s { case "S": return .S, true case "N": return .N, true case "B": return .B, true } return .S, false } // Key schema element Key_Schema_Element :: struct { attribute_name: string, key_type: Key_Type, } // Attribute definition Attribute_Definition :: struct { attribute_name: string, attribute_type: Scalar_Attribute_Type, } // Projection type for indexes Projection_Type :: enum { ALL, KEYS_ONLY, INCLUDE, } // Projection Projection :: struct { projection_type: Projection_Type, non_key_attributes: Maybe([]string), } // Global secondary index Global_Secondary_Index :: struct { index_name: string, key_schema: []Key_Schema_Element, projection: Projection, } // Local secondary index Local_Secondary_Index :: struct { index_name: string, key_schema: []Key_Schema_Element, projection: Projection, } // Table status Table_Status :: enum { CREATING, UPDATING, DELETING, ACTIVE, INACCESSIBLE_ENCRYPTION_CREDENTIALS, ARCHIVING, ARCHIVED, } table_status_to_string :: proc(status: Table_Status) -> string { switch status { case .CREATING: return "CREATING" case .UPDATING: return "UPDATING" case .DELETING: return "DELETING" case .ACTIVE: return "ACTIVE" case .INACCESSIBLE_ENCRYPTION_CREDENTIALS: return "INACCESSIBLE_ENCRYPTION_CREDENTIALS" case .ARCHIVING: return "ARCHIVING" case .ARCHIVED: return "ARCHIVED" } return "ACTIVE" } // Table description Table_Description :: struct { table_name: string, key_schema: []Key_Schema_Element, attribute_definitions: []Attribute_Definition, table_status: Table_Status, creation_date_time: i64, item_count: u64, table_size_bytes: u64, global_secondary_indexes: Maybe([]Global_Secondary_Index), local_secondary_indexes: Maybe([]Local_Secondary_Index), } // DynamoDB operation types Operation :: enum { CreateTable, DeleteTable, DescribeTable, ListTables, UpdateTable, PutItem, GetItem, DeleteItem, UpdateItem, Query, Scan, BatchGetItem, BatchWriteItem, TransactGetItems, TransactWriteItems, Unknown, } operation_from_target :: proc(target: string) -> Operation { prefix :: "DynamoDB_20120810." if !strings.has_prefix(target, prefix) { return .Unknown } op_name := target[len(prefix):] switch op_name { case "CreateTable": return .CreateTable case "DeleteTable": return .DeleteTable case "DescribeTable": return .DescribeTable case "ListTables": return .ListTables case "UpdateTable": return .UpdateTable case "PutItem": return .PutItem case "GetItem": return .GetItem case "DeleteItem": return .DeleteItem case "UpdateItem": return .UpdateItem case "Query": return .Query case "Scan": return .Scan case "BatchGetItem": return .BatchGetItem case "BatchWriteItem": return .BatchWriteItem case "TransactGetItems": return .TransactGetItems case "TransactWriteItems": return .TransactWriteItems } return .Unknown } // DynamoDB error types DynamoDB_Error_Type :: enum { ValidationException, ResourceNotFoundException, ResourceInUseException, ConditionalCheckFailedException, ProvisionedThroughputExceededException, ItemCollectionSizeLimitExceededException, InternalServerError, SerializationException, } error_to_response :: proc(err_type: DynamoDB_Error_Type, message: string) -> string { type_str: string switch err_type { case .ValidationException: type_str = "com.amazonaws.dynamodb.v20120810#ValidationException" case .ResourceNotFoundException: type_str = "com.amazonaws.dynamodb.v20120810#ResourceNotFoundException" case .ResourceInUseException: type_str = "com.amazonaws.dynamodb.v20120810#ResourceInUseException" case .ConditionalCheckFailedException: type_str = "com.amazonaws.dynamodb.v20120810#ConditionalCheckFailedException" case .ProvisionedThroughputExceededException: type_str = "com.amazonaws.dynamodb.v20120810#ProvisionedThroughputExceededException" case .ItemCollectionSizeLimitExceededException: type_str = "com.amazonaws.dynamodb.v20120810#ItemCollectionSizeLimitExceededException" case .InternalServerError: type_str = "com.amazonaws.dynamodb.v20120810#InternalServerError" case .SerializationException: type_str = "com.amazonaws.dynamodb.v20120810#SerializationException" } return fmt.aprintf(`{{"__type":"%s","message":"%s"}}`, type_str, message) } // Deep copy an attribute value attr_value_deep_copy :: proc(attr: Attribute_Value) -> Attribute_Value { switch v in attr { case String: return String(strings.clone(string(v))) case Number: return Number(strings.clone(string(v))) case Binary: return Binary(strings.clone(string(v))) case Bool: return v case Null: return v case String_Set: ss := make([]string, len(v)) for s, i in v { ss[i] = strings.clone(s) } return String_Set(ss) case Number_Set: ns := make([]string, len(v)) for n, i in v { ns[i] = strings.clone(n) } return Number_Set(ns) case Binary_Set: bs := make([]string, len(v)) for b, i in v { bs[i] = strings.clone(b) } return Binary_Set(bs) case List: list := make([]Attribute_Value, len(v)) for item, i in v { list[i] = attr_value_deep_copy(item) } return List(list) case Map: m := make(map[string]Attribute_Value) for key, val in v { m[strings.clone(key)] = attr_value_deep_copy(val) } return Map(m) } return nil } // Free an attribute value attr_value_destroy :: proc(attr: ^Attribute_Value) { switch v in attr { case String: delete(string(v)) case Number: delete(string(v)) case Binary: delete(string(v)) case String_Set: for s in v { delete(s) } delete([]string(v)) case Number_Set: for n in v { delete(n) } delete([]string(v)) case Binary_Set: for b in v { delete(b) } delete([]string(v)) case List: for item in v { item_copy := item attr_value_destroy(&item_copy) } delete([]Attribute_Value(v)) case Map: for key, val in v { delete(key) val_copy := val attr_value_destroy(&val_copy) } delete(map[string]Attribute_Value(v)) case Bool, Null: // Nothing to free } } // Free an item item_destroy :: proc(item: ^Item) { for key, val in item { delete(key) val_copy := val attr_value_destroy(&val_copy) } delete(item^) } ================================================================================ FILE: ./http.odin ================================================================================ package main import "core:fmt" import "core:mem" import vmem "core:mem/virtual" import "core:net" import "core:strings" import "core:strconv" // HTTP Method enumeration HTTP_Method :: enum { GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH, } method_from_string :: proc(s: string) -> HTTP_Method { switch s { case "GET": return .GET case "POST": return .POST case "PUT": return .PUT case "DELETE": return .DELETE case "OPTIONS": return .OPTIONS case "HEAD": return .HEAD case "PATCH": return .PATCH } return .GET } // HTTP Status codes HTTP_Status :: enum u16 { OK = 200, Created = 201, No_Content = 204, Bad_Request = 400, Unauthorized = 401, Forbidden = 403, Not_Found = 404, Method_Not_Allowed = 405, Conflict = 409, Payload_Too_Large = 413, Internal_Server_Error = 500, Service_Unavailable = 503, } // HTTP Header HTTP_Header :: struct { name: string, value: string, } // HTTP Request HTTP_Request :: struct { method: HTTP_Method, path: string, headers: []HTTP_Header, body: []byte, } // Get header value by name (case-insensitive) request_get_header :: proc(req: ^HTTP_Request, name: string) -> Maybe(string) { for header in req.headers { if strings.equal_fold(header.name, name) { return header.value } } return nil } // HTTP Response HTTP_Response :: struct { status: HTTP_Status, headers: [dynamic]HTTP_Header, body: [dynamic]byte, } response_init :: proc(allocator: mem.Allocator) -> HTTP_Response { return HTTP_Response{ status = .OK, headers = make([dynamic]HTTP_Header, allocator), body = make([dynamic]byte, allocator), } } response_set_status :: proc(resp: ^HTTP_Response, status: HTTP_Status) { resp.status = status } response_add_header :: proc(resp: ^HTTP_Response, name: string, value: string) { append(&resp.headers, HTTP_Header{name = name, value = value}) } response_set_body :: proc(resp: ^HTTP_Response, data: []byte) { clear(&resp.body) append(&resp.body, ..data) } // Request handler function type // Takes context pointer, request, and request-scoped allocator Request_Handler :: #type proc(ctx: rawptr, request: ^HTTP_Request, request_alloc: mem.Allocator) -> HTTP_Response // Server configuration Server_Config :: struct { max_body_size: int, // default 100MB max_headers: int, // default 100 read_buffer_size: int, // default 8KB enable_keep_alive: bool, // default true max_requests_per_connection: int, // default 1000 } default_server_config :: proc() -> Server_Config { return Server_Config{ max_body_size = 100 * 1024 * 1024, max_headers = 100, read_buffer_size = 8 * 1024, enable_keep_alive = true, max_requests_per_connection = 1000, } } // Server Server :: struct { allocator: mem.Allocator, endpoint: net.Endpoint, handler: Request_Handler, handler_ctx: rawptr, config: Server_Config, running: bool, socket: Maybe(net.TCP_Socket), } server_init :: proc( allocator: mem.Allocator, host: string, port: int, handler: Request_Handler, handler_ctx: rawptr, config: Server_Config, ) -> (Server, bool) { endpoint, endpoint_ok := net.parse_endpoint(fmt.tprintf("%s:%d", host, port)) if !endpoint_ok { return {}, false } return Server{ allocator = allocator, endpoint = endpoint, handler = handler, handler_ctx = handler_ctx, config = config, running = false, socket = nil, }, true } server_start :: proc(server: ^Server) -> bool { // Create listening socket socket, socket_err := net.listen_tcp(server.endpoint) if socket_err != nil { fmt.eprintfln("Failed to create listening socket: %v", socket_err) return false } server.socket = socket server.running = true fmt.printfln("HTTP server listening on %v", server.endpoint) // Accept loop for server.running { conn, source, accept_err := net.accept_tcp(socket) if accept_err != nil { if server.running { fmt.eprintfln("Accept error: %v", accept_err) } continue } // Handle connection in separate goroutine would go here // For now, handle synchronously (should spawn thread) handle_connection(server, conn, source) } return true } server_stop :: proc(server: ^Server) { server.running = false if sock, ok := server.socket.?; ok { net.close(sock) server.socket = nil } } // Handle a single connection handle_connection :: proc(server: ^Server, conn: net.TCP_Socket, source: net.Endpoint) { defer net.close(conn) request_count := 0 for request_count < server.config.max_requests_per_connection { request_count += 1 // Growing arena for this request arena: vmem.Arena arena_err := vmem.arena_init_growing(&arena) if arena_err != .None { break } defer vmem.arena_destroy(&arena) request_alloc := vmem.arena_allocator(&arena) // TODO: Double check if we want *all* downstream allocations to use the request arena? old := context.allocator context.allocator = request_alloc defer context.allocator = old request, parse_ok := parse_request(conn, request_alloc, server.config) if !parse_ok { break } response := server.handler(server.handler_ctx, &request, request_alloc) send_ok := send_response(conn, &response, request_alloc) if !send_ok { break } // Check keep-alive keep_alive := request_get_header(&request, "Connection") if ka, ok := keep_alive.?; ok { if !strings.equal_fold(ka, "keep-alive") { break } } else if !server.config.enable_keep_alive { break } // Arena is automatically freed here } } // Parse HTTP request parse_request :: proc( conn: net.TCP_Socket, allocator: mem.Allocator, config: Server_Config, ) -> (HTTP_Request, bool) { // Read request line and headers buffer := make([]byte, config.read_buffer_size, allocator) bytes_read, read_err := net.recv_tcp(conn, buffer) if read_err != nil || bytes_read == 0 { return {}, false } request_data := buffer[:bytes_read] // Find end of headers (\r\n\r\n) header_end_idx := strings.index(string(request_data), "\r\n\r\n") if header_end_idx < 0 { return {}, false } header_section := string(request_data[:header_end_idx]) body_start := header_end_idx + 4 // Parse request line lines := strings.split_lines(header_section, allocator) if len(lines) == 0 { return {}, false } request_line := lines[0] parts := strings.split(request_line, " ", allocator) if len(parts) < 3 { return {}, false } method := method_from_string(parts[0]) path := strings.clone(parts[1], allocator) // Parse headers headers := make([dynamic]HTTP_Header, allocator) for i := 1; i < len(lines); i += 1 { line := lines[i] if len(line) == 0 { continue } colon_idx := strings.index(line, ":") if colon_idx < 0 { continue } name := strings.trim_space(line[:colon_idx]) value := strings.trim_space(line[colon_idx+1:]) append(&headers, HTTP_Header{ name = strings.clone(name, allocator), value = strings.clone(value, allocator), }) } // Read body if Content-Length present body: []byte content_length_header := request_get_header_from_slice(headers[:], "Content-Length") if cl, ok := content_length_header.?; ok { content_length := strconv.parse_int(cl) or_else 0 if content_length > 0 && content_length <= config.max_body_size { // Check if we already have the body in buffer existing_body := request_data[body_start:] if len(existing_body) >= content_length { // Body already in buffer body = make([]byte, content_length, allocator) copy(body, existing_body[:content_length]) } else { // Need to read more body = make([]byte, content_length, allocator) copy(body, existing_body) remaining := content_length - len(existing_body) body_written := len(existing_body) for remaining > 0 { chunk_size := min(remaining, config.read_buffer_size) chunk := make([]byte, chunk_size, allocator) n, err := net.recv_tcp(conn, chunk) if err != nil || n == 0 { return {}, false } copy(body[body_written:], chunk[:n]) body_written += n remaining -= n } } } } return HTTP_Request{ method = method, path = path, headers = headers[:], body = body, }, true } // Helper to get header from slice request_get_header_from_slice :: proc(headers: []HTTP_Header, name: string) -> Maybe(string) { for header in headers { if strings.equal_fold(header.name, name) { return header.value } } return nil } // Send HTTP response send_response :: proc(conn: net.TCP_Socket, resp: ^HTTP_Response, allocator: mem.Allocator) -> bool { // Build response string builder := strings.builder_make(allocator) defer strings.builder_destroy(&builder) // Status line strings.write_string(&builder, "HTTP/1.1 ") strings.write_int(&builder, int(resp.status)) strings.write_string(&builder, " ") strings.write_string(&builder, status_text(resp.status)) strings.write_string(&builder, "\r\n") // Headers response_add_header(resp, "Content-Length", fmt.tprintf("%d", len(resp.body))) for header in resp.headers { strings.write_string(&builder, header.name) strings.write_string(&builder, ": ") strings.write_string(&builder, header.value) strings.write_string(&builder, "\r\n") } // End of headers strings.write_string(&builder, "\r\n") // Send headers header_bytes := transmute([]byte)strings.to_string(builder) _, send_err := net.send_tcp(conn, header_bytes) if send_err != nil { return false } // Send body if len(resp.body) > 0 { _, send_err = net.send_tcp(conn, resp.body[:]) if send_err != nil { return false } } return true } // Get status text for status code status_text :: proc(status: HTTP_Status) -> string { switch status { case .OK: return "OK" case .Created: return "Created" case .No_Content: return "No Content" case .Bad_Request: return "Bad Request" case .Unauthorized: return "Unauthorized" case .Forbidden: return "Forbidden" case .Not_Found: return "Not Found" case .Method_Not_Allowed: return "Method Not Allowed" case .Conflict: return "Conflict" case .Payload_Too_Large: return "Payload Too Large" case .Internal_Server_Error: return "Internal Server Error" case .Service_Unavailable: return "Service Unavailable" } return "Unknown" } ================================================================================ FILE: ./key_codec/key_codec.odin ================================================================================ package key_codec import "core:bytes" import "core:encoding/varint" import "core:mem" // Entity type prefix bytes for namespacing Entity_Type :: enum u8 { Meta = 0x01, // Table metadata Data = 0x02, // Item data GSI = 0x03, // Global secondary index LSI = 0x04, // Local secondary index } // Encode a varint length prefix encode_varint :: proc(buf: ^bytes.Buffer, value: int) { temp: [10]byte n := varint.encode_u64(temp[:], u64(value)) bytes.buffer_write(buf, temp[:n]) } // Decode a varint length prefix decode_varint :: proc(data: []byte, offset: ^int) -> (value: int, ok: bool) { if offset^ >= len(data) { return 0, false } val, n := varint.decode_u64(data[offset^:]) if n <= 0 { return 0, false } offset^ += n return int(val), true } // Build metadata key: [meta][table_name] build_meta_key :: proc(table_name: string) -> []byte { buf: bytes.Buffer bytes.buffer_init_allocator(&buf, 0, 256, context.allocator) // Write entity type bytes.buffer_write_byte(&buf, u8(Entity_Type.Meta)) // Write table name with length prefix encode_varint(&buf, len(table_name)) bytes.buffer_write_string(&buf, table_name) return bytes.buffer_to_bytes(&buf) } // Build data key: [data][table_name][pk_value][sk_value?] build_data_key :: proc(table_name: string, pk_value: []byte, sk_value: Maybe([]byte)) -> []byte { buf: bytes.Buffer bytes.buffer_init_allocator(&buf, 0, 512, context.allocator) // Write entity type bytes.buffer_write_byte(&buf, u8(Entity_Type.Data)) // Write table name encode_varint(&buf, len(table_name)) bytes.buffer_write_string(&buf, table_name) // Write partition key encode_varint(&buf, len(pk_value)) bytes.buffer_write(&buf, pk_value) // Write sort key if present if sk, ok := sk_value.?; ok { encode_varint(&buf, len(sk)) bytes.buffer_write(&buf, sk) } return bytes.buffer_to_bytes(&buf) } // Build table prefix for scanning: [data][table_name] build_table_prefix :: proc(table_name: string) -> []byte { buf: bytes.Buffer bytes.buffer_init_allocator(&buf, 0, 256, context.allocator) // Write entity type bytes.buffer_write_byte(&buf, u8(Entity_Type.Data)) // Write table name encode_varint(&buf, len(table_name)) bytes.buffer_write_string(&buf, table_name) return bytes.buffer_to_bytes(&buf) } // Build partition prefix for querying: [data][table_name][pk_value] build_partition_prefix :: proc(table_name: string, pk_value: []byte) -> []byte { buf: bytes.Buffer bytes.buffer_init_allocator(&buf, 0, 512, context.allocator) // Write entity type bytes.buffer_write_byte(&buf, u8(Entity_Type.Data)) // Write table name encode_varint(&buf, len(table_name)) bytes.buffer_write_string(&buf, table_name) // Write partition key encode_varint(&buf, len(pk_value)) bytes.buffer_write(&buf, pk_value) return bytes.buffer_to_bytes(&buf) } // Build GSI key: [gsi][table_name][index_name][gsi_pk][gsi_sk?] build_gsi_key :: proc(table_name: string, index_name: string, gsi_pk: []byte, gsi_sk: Maybe([]byte)) -> []byte { buf: bytes.Buffer bytes.buffer_init_allocator(&buf, 0, 512, context.allocator) // Write entity type bytes.buffer_write_byte(&buf, u8(Entity_Type.GSI)) // Write table name encode_varint(&buf, len(table_name)) bytes.buffer_write_string(&buf, table_name) // Write index name encode_varint(&buf, len(index_name)) bytes.buffer_write_string(&buf, index_name) // Write GSI partition key encode_varint(&buf, len(gsi_pk)) bytes.buffer_write(&buf, gsi_pk) // Write GSI sort key if present if sk, ok := gsi_sk.?; ok { encode_varint(&buf, len(sk)) bytes.buffer_write(&buf, sk) } return bytes.buffer_to_bytes(&buf) } // Build LSI key: [lsi][table_name][index_name][pk][lsi_sk] build_lsi_key :: proc(table_name: string, index_name: string, pk: []byte, lsi_sk: []byte) -> []byte { buf: bytes.Buffer bytes.buffer_init_allocator(&buf, 0, 512, context.allocator) // Write entity type bytes.buffer_write_byte(&buf, u8(Entity_Type.LSI)) // Write table name encode_varint(&buf, len(table_name)) bytes.buffer_write_string(&buf, table_name) // Write index name encode_varint(&buf, len(index_name)) bytes.buffer_write_string(&buf, index_name) // Write partition key encode_varint(&buf, len(pk)) bytes.buffer_write(&buf, pk) // Write LSI sort key encode_varint(&buf, len(lsi_sk)) bytes.buffer_write(&buf, lsi_sk) return bytes.buffer_to_bytes(&buf) } // Key decoder for reading binary keys Key_Decoder :: struct { data: []byte, pos: int, } decoder_init :: proc(data: []byte) -> Key_Decoder { return Key_Decoder{data = data, pos = 0} } decoder_read_entity_type :: proc(decoder: ^Key_Decoder) -> (Entity_Type, bool) { if decoder.pos >= len(decoder.data) { return .Meta, false } entity_type := Entity_Type(decoder.data[decoder.pos]) decoder.pos += 1 return entity_type, true } decoder_read_segment :: proc(decoder: ^Key_Decoder) -> (segment: []byte, ok: bool) { // Read length length := decode_varint(decoder.data, &decoder.pos) or_return // Read data if decoder.pos + length > len(decoder.data) { return nil, false } // Return slice (owned by caller via context.allocator) segment = make([]byte, length, context.allocator) copy(segment, decoder.data[decoder.pos:decoder.pos + length]) decoder.pos += length return segment, true } decoder_read_segment_borrowed :: proc(decoder: ^Key_Decoder) -> (segment: []byte, ok: bool) { // Read length length := decode_varint(decoder.data, &decoder.pos) or_return // Return borrowed slice if decoder.pos + length > len(decoder.data) { return nil, false } segment = decoder.data[decoder.pos:decoder.pos + length] decoder.pos += length return segment, true } decoder_has_more :: proc(decoder: ^Key_Decoder) -> bool { return decoder.pos < len(decoder.data) } // Decode a data key back into components Decoded_Data_Key :: struct { table_name: string, pk_value: []byte, sk_value: Maybe([]byte), } decode_data_key :: proc(key: []byte) -> (result: Decoded_Data_Key, ok: bool) { decoder := decoder_init(key) // Read and verify entity type entity_type := decoder_read_entity_type(&decoder) or_return if entity_type != .Data { return {}, false } // Read table name table_name_bytes := decoder_read_segment(&decoder) or_return result.table_name = string(table_name_bytes) // Read partition key result.pk_value = decoder_read_segment(&decoder) or_return // Read sort key if present if decoder_has_more(&decoder) { sk := decoder_read_segment(&decoder) or_return result.sk_value = sk } return result, true } ================================================================================ FILE: ./main.odin ================================================================================ package main import "core:fmt" import "core:mem" import "core:os" import "core:strconv" //import "core:strings" // I know we'll use in future but because we're not right now, compiler is complaining import "rocksdb" Config :: struct { host: string, port: int, data_dir: string, verbose: bool, } main :: proc() { // Parse configuration config := parse_config() // Print banner print_banner(config) // Create data directory os.make_directory(config.data_dir) // Initialize storage engine db, err := rocksdb.db_open(config.data_dir, true) if err != .None { fmt.eprintln("Failed to initialize storage:", err) os.exit(1) } defer rocksdb.db_close(&db) fmt.printfln("Storage engine initialized at %s", config.data_dir) fmt.printfln("Starting DynamoDB-compatible server on %s:%d", config.host, config.port) // Create HTTP server server_config := default_server_config() // For now, use a simple echo handler until we implement the full DynamoDB handler server, server_ok := server_init( context.allocator, config.host, config.port, handle_http_request, &db, server_config, ) if !server_ok { fmt.eprintln("Failed to initialize HTTP server") os.exit(1) } defer server_stop(&server) fmt.println("Ready to accept connections!") // Start server (blocks) if !server_start(&server) { fmt.eprintln("Server failed to start") os.exit(1) } } // Temporary HTTP request handler // TODO: Replace with full DynamoDB handler once dynamodb/handler.odin is implemented handle_http_request :: proc(ctx: rawptr, request: ^HTTP_Request, request_alloc: mem.Allocator) -> HTTP_Response { //db := cast(^rocksdb.DB)ctx // I know we'll use in future but because we're not right now, compiler is complaining response := response_init(request_alloc) response_add_header(&response, "Content-Type", "application/x-amz-json-1.0") response_add_header(&response, "x-amzn-RequestId", "local-request-id") // Get X-Amz-Target header target := request_get_header(request, "X-Amz-Target") if t, ok := target.?; ok { // Echo back the operation for now body := fmt.aprintf("{{\"operation\":\"%s\",\"status\":\"not_implemented\"}}", t) response_set_body(&response, transmute([]byte)body) } else { response_set_status(&response, .Bad_Request) response_set_body(&response, transmute([]byte)string("{\"error\":\"Missing X-Amz-Target header\"}")) } return response } parse_config :: proc() -> Config { config := Config{ host = "0.0.0.0", port = 8002, data_dir = "./data", verbose = false, } // Environment variables if port_str, env_ok := os.lookup_env("JORMUN_PORT"); env_ok { if port, parse_ok := strconv.parse_int(port_str); parse_ok { config.port = port } } if host, ok := os.lookup_env("JORMUN_HOST"); ok { config.host = host } if data_dir, ok := os.lookup_env("JORMUN_DATA_DIR"); ok { config.data_dir = data_dir } if verbose, ok := os.lookup_env("JORMUN_VERBOSE"); ok { config.verbose = verbose == "1" } // TODO: Parse command line arguments return config } print_banner :: proc(config: Config) { banner := ` ╔═══════════════════════════════════════════════╗ ║ ║ ║ ╦╔═╗╦═╗╔╦╗╦ ╦╔╗╔╔╦╗╔╗ ║ ║ ║║ ║╠╦╝║║║║ ║║║║ ║║╠╩╗ ║ ║ ╚╝╚═╝╩╚═╩ ╩╚═╝╝╚╝═╩╝╚═╝ ║ ║ ║ ║ DynamoDB-Compatible Database ║ ║ Powered by RocksDB + Odin ║ ║ ║ ╚═══════════════════════════════════════════════╝ ` fmt.println(banner) fmt.printfln(" Port: %d | Data Dir: %s\n", config.port, config.data_dir) } ================================================================================ FILE: ./ols.json ================================================================================ { "$schema": "https://raw.githubusercontent.com/DanielGavin/ols/master/misc/ols.schema.json", "enable_document_symbols": true, "enable_hover": true, "enable_snippets": true } ================================================================================ FILE: ./QUICKSTART.md ================================================================================ # JormunDB Quick Start Guide Get JormunDB running in 5 minutes. ## Prerequisites ### 1. Install Odin **macOS:** ```bash # Using Homebrew brew install odin # Or download from https://odin-lang.org/docs/install/ ``` **Ubuntu/Debian:** ```bash # Download latest release wget https://github.com/odin-lang/Odin/releases/latest/download/odin-ubuntu-amd64.tar.gz tar -xzf odin-ubuntu-amd64.tar.gz sudo mv odin /usr/local/bin/ # Verify odin version ``` **From Source:** ```bash git clone https://github.com/odin-lang/Odin cd Odin make sudo cp odin /usr/local/bin/ ``` ### 2. Install RocksDB **macOS:** ```bash brew install rocksdb ``` **Ubuntu/Debian:** ```bash sudo apt update sudo apt install -y librocksdb-dev libsnappy-dev liblz4-dev libzstd-dev libbz2-dev ``` **Arch Linux:** ```bash sudo pacman -S rocksdb ``` ### 3. Verify Installation ```bash # Check Odin odin version # Check RocksDB pkg-config --libs rocksdb # Should output: -lrocksdb -lstdc++ ... ``` ## Building JormunDB ### Clone and Build ```bash # Clone the repository git clone https://github.com/yourusername/jormundb.git cd jormundb # Build debug version make build # Or build optimized release make release ``` ### Troubleshooting Build Issues **"cannot find rocksdb"** ```bash # Check RocksDB installation pkg-config --cflags --libs rocksdb # If not found, install RocksDB (see prerequisites) ``` **"odin: command not found"** ```bash # Add Odin to PATH export PATH=$PATH:/path/to/odin # Or install system-wide (see prerequisites) ``` ## Running the Server ### Basic Usage ```bash # Run with defaults (localhost:8002, ./data directory) make run ``` You should see: ``` ╔═══════════════════════════════════════════════╗ ║ ║ ║ ╦╔═╗╦═╗╔╦╗╦ ╦╔╗╔╔╦╗╔╗ ║ ║ ║║ ║╠╦╝║║║║ ║║║║ ║║╠╩╗ ║ ║ ╚╝╚═╝╩╚═╩ ╩╚═╝╝╚╝═╩╝╚═╝ ║ ║ ║ ║ DynamoDB-Compatible Database ║ ║ Powered by RocksDB + Odin ║ ║ ║ ╚═══════════════════════════════════════════════╝ Port: 8002 | Data Dir: ./data Storage engine initialized at ./data Starting DynamoDB-compatible server on 0.0.0.0:8002 Ready to accept connections! ``` ### Custom Configuration ```bash # Custom port make run PORT=9000 # Custom data directory make run DATA_DIR=/tmp/jormun # Enable verbose logging make run VERBOSE=1 # Combine options make run PORT=9000 DATA_DIR=/var/jormun VERBOSE=1 ``` ### Environment Variables ```bash # Set via environment export JORMUN_PORT=9000 export JORMUN_HOST=127.0.0.1 export JORMUN_DATA_DIR=/var/jormun make run ``` ## Testing with AWS CLI ### Install AWS CLI **macOS:** ```bash brew install awscli ``` **Ubuntu/Debian:** ```bash sudo apt install awscli ``` **Verify:** ```bash aws --version ``` ### Configure AWS CLI (for local use) ```bash # Set dummy credentials (required but not checked by JormunDB) aws configure # AWS Access Key ID: dummy # AWS Secret Access Key: dummy # Default region name: us-east-1 # Default output format: json ``` ### Basic Operations **Create a Table:** ```bash aws dynamodb create-table \ --endpoint-url http://localhost:8002 \ --table-name Users \ --key-schema \ AttributeName=id,KeyType=HASH \ --attribute-definitions \ AttributeName=id,AttributeType=S \ --billing-mode PAY_PER_REQUEST ``` **List Tables:** ```bash aws dynamodb list-tables --endpoint-url http://localhost:8002 ``` **Put an Item:** ```bash aws dynamodb put-item \ --endpoint-url http://localhost:8002 \ --table-name Users \ --item '{ "id": {"S": "user123"}, "name": {"S": "Alice"}, "age": {"N": "30"}, "email": {"S": "alice@example.com"} }' ``` **Get an Item:** ```bash aws dynamodb get-item \ --endpoint-url http://localhost:8002 \ --table-name Users \ --key '{"id": {"S": "user123"}}' ``` **Query Items:** ```bash aws dynamodb query \ --endpoint-url http://localhost:8002 \ --table-name Users \ --key-condition-expression "id = :id" \ --expression-attribute-values '{ ":id": {"S": "user123"} }' ``` **Scan Table:** ```bash aws dynamodb scan \ --endpoint-url http://localhost:8002 \ --table-name Users ``` **Delete an Item:** ```bash aws dynamodb delete-item \ --endpoint-url http://localhost:8002 \ --table-name Users \ --key '{"id": {"S": "user123"}}' ``` **Delete a Table:** ```bash aws dynamodb delete-table \ --endpoint-url http://localhost:8002 \ --table-name Users ``` ## Testing with AWS SDK ### Node.js Example ```javascript const { DynamoDBClient, PutItemCommand, GetItemCommand } = require("@aws-sdk/client-dynamodb"); const client = new DynamoDBClient({ endpoint: "http://localhost:8002", region: "us-east-1", credentials: { accessKeyId: "dummy", secretAccessKey: "dummy" } }); async function test() { // Put an item await client.send(new PutItemCommand({ TableName: "Users", Item: { id: { S: "user123" }, name: { S: "Alice" } } })); // Get the item const result = await client.send(new GetItemCommand({ TableName: "Users", Key: { id: { S: "user123" } } })); console.log(result.Item); } test(); ``` ### Python Example ```python import boto3 dynamodb = boto3.client( 'dynamodb', endpoint_url='http://localhost:8002', region_name='us-east-1', aws_access_key_id='dummy', aws_secret_access_key='dummy' ) # Put an item dynamodb.put_item( TableName='Users', Item={ 'id': {'S': 'user123'}, 'name': {'S': 'Alice'} } ) # Get the item response = dynamodb.get_item( TableName='Users', Key={'id': {'S': 'user123'}} ) print(response['Item']) ``` ## Development Workflow ### Quick Rebuild ```bash # Fast rebuild and run make quick ``` ### Clean Start ```bash # Remove all build artifacts and data make clean # Build and run fresh make dev ``` ### Running Tests ```bash # Run unit tests make test # Run AWS CLI integration tests make aws-test ``` ### Code Formatting ```bash # Format all Odin files make fmt ``` ## Common Issues ### Port Already in Use ```bash # Check what's using port 8002 lsof -i :8002 # Use a different port make run PORT=9000 ``` ### Cannot Create Data Directory ```bash # Create with proper permissions mkdir -p ./data chmod 755 ./data # Or use a different directory make run DATA_DIR=/tmp/jormun ``` ### RocksDB Not Found ```bash # Check installation pkg-config --libs rocksdb # Install if missing (see Prerequisites) ``` ### Odin Compiler Errors ```bash # Check Odin version odin version # Update Odin if needed brew upgrade odin # macOS # or download latest from odin-lang.org ``` ## Next Steps - Read [ARCHITECTURE.md](ARCHITECTURE.md) for internals - Check [TODO.md](TODO.md) for implementation status - Browse source code in `dynamodb/`, `rocksdb/`, etc. - Contribute! See [CONTRIBUTING.md](CONTRIBUTING.md) ## Getting Help - **Issues**: https://github.com/yourusername/jormundb/issues - **Discussions**: https://github.com/yourusername/jormundb/discussions - **Odin Discord**: https://discord.gg/sVBPHEv ## Benchmarking ```bash # Run benchmarks make bench # Profile memory usage make profile # Load test ab -n 10000 -c 100 -p item.json -T application/json \ http://localhost:8002/ ``` ## Production Deployment JormunDB is designed for **local development only**. For production, use: - AWS DynamoDB (managed service) - DynamoDB Accelerator (DAX) - ScyllaDB (DynamoDB-compatible) ## Uninstalling ```bash # Remove build artifacts make clean # Remove installed binary (if installed) make uninstall # Remove data directory rm -rf ./data ``` --- **Happy coding! 🚀** For questions or issues, please open a GitHub issue or join our Discord. ================================================================================ FILE: ./README.md ================================================================================ # JormunDB A high-performance, DynamoDB-compatible database server written in Odin, backed by RocksDB. ``` ╦╔═╗╦═╗╔╦╗╦ ╦╔╗╔╔╦╗╔╗ ║║ ║╠╦╝║║║║ ║║║║ ║║╠╩╗ ╚╝╚═╝╩╚═╩ ╩╚═╝╝╚╝═╩╝╚═╝ DynamoDB-Compatible Database Powered by RocksDB + Odin ``` ## What is JormunDB? JormunDB (formerly ZynamoDB) is a local DynamoDB replacement that speaks the DynamoDB wire protocol. Point your AWS SDK or CLI at it and use it as a drop-in development database. **Why Odin?** The original Zig implementation suffered from explicit allocator threading—every function taking an `allocator` parameter, every allocation needing `errdefer` cleanup. Odin's implicit context allocator system eliminates this ceremony: one `context.allocator = arena_allocator` at the request handler entry and everything downstream just works. ## Features - ✅ **DynamoDB Wire Protocol**: Works with AWS SDKs and CLI out of the box - ✅ **Binary Storage**: Efficient TLV encoding for items, varint-prefixed keys - ✅ **Arena-per-Request**: Zero explicit memory management in business logic - ✅ **Table Operations**: CreateTable, DeleteTable, DescribeTable, ListTables - ✅ **Item Operations**: PutItem, GetItem, DeleteItem - ✅ **Query & Scan**: With pagination support (Limit, ExclusiveStartKey) - ✅ **Expression Parsing**: KeyConditionExpression for Query operations - ✅ **Persistent Storage**: RocksDB-backed with full ACID guarantees - ✅ **Concurrency**: Table-level RW locks for safe concurrent access ## Quick Start ### Prerequisites - Odin compiler (latest) - RocksDB development libraries - Standard compression libraries (snappy, lz4, zstd, etc.) #### macOS (Homebrew) ```bash brew install rocksdb odin ``` #### Ubuntu/Debian ```bash sudo apt install librocksdb-dev libsnappy-dev liblz4-dev libzstd-dev libbz2-dev # Install Odin from https://odin-lang.org/docs/install/ ``` ### Build & Run ```bash # Build the server make build # Run with default settings (localhost:8002, ./data directory) make run # Run with custom port make run PORT=9000 # Run with custom data directory make run DATA_DIR=/tmp/jormundb ``` ### Test with AWS CLI ```bash # Create a table aws dynamodb create-table \ --endpoint-url http://localhost:8002 \ --table-name Users \ --key-schema AttributeName=id,KeyType=HASH \ --attribute-definitions AttributeName=id,AttributeType=S \ --billing-mode PAY_PER_REQUEST # Put an item aws dynamodb put-item \ --endpoint-url http://localhost:8002 \ --table-name Users \ --item '{"id":{"S":"user123"},"name":{"S":"Alice"},"age":{"N":"30"}}' # Get an item aws dynamodb get-item \ --endpoint-url http://localhost:8002 \ --table-name Users \ --key '{"id":{"S":"user123"}}' # Query items aws dynamodb query \ --endpoint-url http://localhost:8002 \ --table-name Users \ --key-condition-expression "id = :id" \ --expression-attribute-values '{":id":{"S":"user123"}}' # Scan table aws dynamodb scan \ --endpoint-url http://localhost:8002 \ --table-name Users ``` ## Architecture ``` HTTP Request (POST /) ↓ X-Amz-Target header → Operation routing ↓ JSON body → DynamoDB types ↓ Storage engine → RocksDB operations ↓ Binary encoding → Disk ↓ JSON response → Client ``` ### Module Structure ``` jormundb/ ├── rocksdb/ - C FFI bindings to librocksdb ├── dynamodb/ - Core types and operations │ ├── types.odin - AttributeValue, Item, Key, etc. │ ├── json.odin - DynamoDB JSON serialization │ ├── storage.odin - Storage engine with RocksDB │ └── handler.odin - HTTP request handlers ├── key_codec/ - Binary key encoding (varint-prefixed) ├── item_codec/ - Binary TLV item encoding └── main.odin - HTTP server and entry point ``` ### Storage Format **Keys** (varint-length-prefixed segments): ``` Meta: [0x01][len][table_name] Data: [0x02][len][table_name][len][pk_value][len][sk_value]? GSI: [0x03][len][table_name][len][index_name][len][gsi_pk][len][gsi_sk]? LSI: [0x04][len][table_name][len][index_name][len][pk][len][lsi_sk] ``` **Values** (TLV binary encoding): ``` [attr_count:varint] [name_len:varint][name:bytes][type_tag:u8][value_encoded:bytes]... Type tags: String=0x01, Number=0x02, Binary=0x03, Bool=0x04, Null=0x05 SS=0x10, NS=0x11, BS=0x12 List=0x20, Map=0x21 ``` ## Memory Management JormunDB uses Odin's context allocator system for elegant memory management: ```odin // Request handler entry point handle_request :: proc(conn: net.TCP_Socket) { arena: mem.Arena mem.arena_init(&arena, make([]byte, mem.Megabyte * 4)) defer mem.arena_destroy(&arena) context.allocator = mem.arena_allocator(&arena) // Everything below uses the arena automatically // No manual frees, no errdefer cleanup needed request := parse_request() // Uses context.allocator response := process(request) // Uses context.allocator send_response(response) // Uses context.allocator // Arena is freed here automatically } ``` Long-lived data (table metadata, locks) uses the default allocator. Request-scoped data uses the arena. ## Development ```bash # Build debug version make build # Build optimized release make release # Run tests make test # Format code make fmt # Clean build artifacts make clean # Run with custom settings make run PORT=9000 DATA_DIR=/tmp/db VERBOSE=1 ``` ## Performance From benchmarks on the original Zig version (Odin expected to be similar or better): ``` Sequential Writes | 10000 ops | 245.32 ms | 40765 ops/sec Random Reads | 10000 ops | 312.45 ms | 32006 ops/sec Batch Writes | 10000 ops | 89.23 ms | 112071 ops/sec PutItem | 5000 ops | 892.34 ms | 5604 ops/sec GetItem | 5000 ops | 678.91 ms | 7365 ops/sec Scan (full table) | 5000 ops | 234.56 ms | 21320 ops/sec ``` ## API Compatibility ### Supported Operations - ✅ CreateTable - ✅ DeleteTable - ✅ DescribeTable - ✅ ListTables - ✅ PutItem - ✅ GetItem - ✅ DeleteItem - ✅ Query (with KeyConditionExpression) - ✅ Scan (with pagination) ### Coming Soon - ⏳ UpdateItem (with UpdateExpression) - ⏳ BatchWriteItem - ⏳ BatchGetItem - ⏳ Global Secondary Indexes - ⏳ Local Secondary Indexes - ⏳ ConditionExpression - ⏳ FilterExpression - ⏳ ProjectionExpression ## Configuration ### Environment Variables ```bash JORMUN_PORT=8002 # Server port JORMUN_HOST=0.0.0.0 # Bind address JORMUN_DATA_DIR=./data # RocksDB data directory JORMUN_VERBOSE=1 # Enable verbose logging ``` ### Command Line Arguments ```bash ./jormundb --port 9000 --host 127.0.0.1 --data-dir /var/db --verbose ``` ## Troubleshooting ### "Cannot open RocksDB" Ensure RocksDB libraries are installed and the data directory is writable: ```bash # Check RocksDB installation pkg-config --libs rocksdb # Check permissions mkdir -p ./data chmod 755 ./data ``` ### "Connection refused" Check if the port is already in use: ```bash lsof -i :8002 ``` ### "Invalid JSON" errors Ensure you're using the correct DynamoDB JSON format: ```json { "TableName": "Users", "Item": { "id": {"S": "user123"}, "age": {"N": "30"} } } ``` ## License MIT License - see LICENSE file for details. ## Credits - Inspired by DynamoDB Local - Built with [Odin](https://odin-lang.org/) - Powered by [RocksDB](https://rocksdb.org/) - Originally implemented as ZynamoDB in Zig ## Contributing Contributions welcome! Please: 1. Format code with `make fmt` 2. Run tests with `make test` 3. Update documentation as needed 4. Follow Odin idioms (context allocators, explicit returns, etc.) --- **Why "Jormun"?** Jörmungandr, the World Serpent from Norse mythology—a fitting name for something that wraps around your data. Also, it sounds cool. ================================================================================ FILE: ./rocksdb/rocksdb.odin ================================================================================ package rocksdb import "core:c" import "core:fmt" foreign import rocksdb "system:rocksdb" // In order to use RocksDB's WAL replication helpers, we need to import the C++ library so we use this shim //foreign import rocksdb_shim "system:jormun_rocksdb_shim" // I know we'll use in future but because we're not right now, compiler is complaining // RocksDB C API types RocksDB_T :: distinct rawptr RocksDB_Options :: distinct rawptr RocksDB_WriteOptions :: distinct rawptr RocksDB_ReadOptions :: distinct rawptr RocksDB_WriteBatch :: distinct rawptr RocksDB_Iterator :: distinct rawptr RocksDB_FlushOptions :: distinct rawptr // Error type Error :: enum { None, OpenFailed, WriteFailed, ReadFailed, DeleteFailed, InvalidArgument, Corruption, NotFound, IOError, Unknown, } // Database handle with options DB :: struct { handle: RocksDB_T, options: RocksDB_Options, write_options: RocksDB_WriteOptions, read_options: RocksDB_ReadOptions, } // Foreign C functions @(default_calling_convention = "c") foreign rocksdb { // Database operations rocksdb_open :: proc(options: RocksDB_Options, path: cstring, errptr: ^cstring) -> RocksDB_T --- rocksdb_close :: proc(db: RocksDB_T) --- // Options rocksdb_options_create :: proc() -> RocksDB_Options --- rocksdb_options_destroy :: proc(options: RocksDB_Options) --- rocksdb_options_set_create_if_missing :: proc(options: RocksDB_Options, val: c.uchar) --- rocksdb_options_increase_parallelism :: proc(options: RocksDB_Options, total_threads: c.int) --- rocksdb_options_optimize_level_style_compaction :: proc(options: RocksDB_Options, memtable_memory_budget: c.uint64_t) --- rocksdb_options_set_compression :: proc(options: RocksDB_Options, compression: c.int) --- // Write options rocksdb_writeoptions_create :: proc() -> RocksDB_WriteOptions --- rocksdb_writeoptions_destroy :: proc(options: RocksDB_WriteOptions) --- // Read options rocksdb_readoptions_create :: proc() -> RocksDB_ReadOptions --- rocksdb_readoptions_destroy :: proc(options: RocksDB_ReadOptions) --- // Put/Get/Delete rocksdb_put :: proc(db: RocksDB_T, options: RocksDB_WriteOptions, key: [^]byte, keylen: c.size_t, val: [^]byte, vallen: c.size_t, errptr: ^cstring) --- rocksdb_get :: proc(db: RocksDB_T, options: RocksDB_ReadOptions, key: [^]byte, keylen: c.size_t, vallen: ^c.size_t, errptr: ^cstring) -> [^]byte --- rocksdb_delete :: proc(db: RocksDB_T, options: RocksDB_WriteOptions, key: [^]byte, keylen: c.size_t, errptr: ^cstring) --- // Flush rocksdb_flushoptions_create :: proc() -> RocksDB_FlushOptions --- rocksdb_flushoptions_destroy :: proc(options: RocksDB_FlushOptions) --- rocksdb_flush :: proc(db: RocksDB_T, options: RocksDB_FlushOptions, errptr: ^cstring) --- // Write batch rocksdb_writebatch_create :: proc() -> RocksDB_WriteBatch --- rocksdb_writebatch_destroy :: proc(batch: RocksDB_WriteBatch) --- rocksdb_writebatch_put :: proc(batch: RocksDB_WriteBatch, key: [^]byte, keylen: c.size_t, val: [^]byte, vallen: c.size_t) --- rocksdb_writebatch_delete :: proc(batch: RocksDB_WriteBatch, key: [^]byte, keylen: c.size_t) --- rocksdb_writebatch_clear :: proc(batch: RocksDB_WriteBatch) --- rocksdb_write :: proc(db: RocksDB_T, options: RocksDB_WriteOptions, batch: RocksDB_WriteBatch, errptr: ^cstring) --- // Iterator rocksdb_create_iterator :: proc(db: RocksDB_T, options: RocksDB_ReadOptions) -> RocksDB_Iterator --- rocksdb_iter_destroy :: proc(iter: RocksDB_Iterator) --- rocksdb_iter_seek_to_first :: proc(iter: RocksDB_Iterator) --- rocksdb_iter_seek_to_last :: proc(iter: RocksDB_Iterator) --- rocksdb_iter_seek :: proc(iter: RocksDB_Iterator, key: [^]byte, keylen: c.size_t) --- rocksdb_iter_seek_for_prev :: proc(iter: RocksDB_Iterator, key: [^]byte, keylen: c.size_t) --- rocksdb_iter_valid :: proc(iter: RocksDB_Iterator) -> c.uchar --- rocksdb_iter_next :: proc(iter: RocksDB_Iterator) --- rocksdb_iter_prev :: proc(iter: RocksDB_Iterator) --- rocksdb_iter_key :: proc(iter: RocksDB_Iterator, klen: ^c.size_t) -> [^]byte --- rocksdb_iter_value :: proc(iter: RocksDB_Iterator, vlen: ^c.size_t) -> [^]byte --- // Memory management rocksdb_free :: proc(ptr: rawptr) --- } // Compression types ROCKSDB_NO_COMPRESSION :: 0 ROCKSDB_SNAPPY_COMPRESSION :: 1 ROCKSDB_ZLIB_COMPRESSION :: 2 ROCKSDB_BZIP2_COMPRESSION :: 3 ROCKSDB_LZ4_COMPRESSION :: 4 ROCKSDB_LZ4HC_COMPRESSION :: 5 ROCKSDB_ZSTD_COMPRESSION :: 7 // Open a database db_open :: proc(path: string, create_if_missing := true) -> (DB, Error) { options := rocksdb_options_create() if options == nil { return {}, .Unknown } // Set create if missing rocksdb_options_set_create_if_missing(options, create_if_missing ? 1 : 0) // Performance optimizations rocksdb_options_increase_parallelism(options, 4) rocksdb_options_optimize_level_style_compaction(options, 512 * 1024 * 1024) rocksdb_options_set_compression(options, ROCKSDB_LZ4_COMPRESSION) // Create write and read options write_options := rocksdb_writeoptions_create() if write_options == nil { rocksdb_options_destroy(options) return {}, .Unknown } read_options := rocksdb_readoptions_create() if read_options == nil { rocksdb_writeoptions_destroy(write_options) rocksdb_options_destroy(options) return {}, .Unknown } // Open database err: cstring path_cstr := fmt.ctprintf("%s", path) handle := rocksdb_open(options, path_cstr, &err) if err != nil { defer rocksdb_free(rawptr(err)) // Cast it here and now so we don't deal with issues from FFI down the line rocksdb_readoptions_destroy(read_options) rocksdb_writeoptions_destroy(write_options) rocksdb_options_destroy(options) return {}, .OpenFailed } return DB{ handle = handle, options = options, write_options = write_options, read_options = read_options, }, .None } // Close database db_close :: proc(db: ^DB) { rocksdb_readoptions_destroy(db.read_options) rocksdb_writeoptions_destroy(db.write_options) rocksdb_close(db.handle) rocksdb_options_destroy(db.options) } // Put key-value pair db_put :: proc(db: ^DB, key: []byte, value: []byte) -> Error { err: cstring rocksdb_put( db.handle, db.write_options, raw_data(key), c.size_t(len(key)), raw_data(value), c.size_t(len(value)), &err, ) if err != nil { defer rocksdb_free(rawptr(err)) // Cast it here and now so we don't deal with issues from FFI down the line return .WriteFailed } return .None } // Get value by key (returns owned slice - caller must free) db_get :: proc(db: ^DB, key: []byte) -> (value: []byte, err: Error) { errptr: cstring value_len: c.size_t value_ptr := rocksdb_get( db.handle, db.read_options, raw_data(key), c.size_t(len(key)), &value_len, &errptr, ) if errptr != nil { defer rocksdb_free(rawptr(errptr)) // Cast it here and now so we don't deal with issues from FFI down the line return nil, .ReadFailed } if value_ptr == nil { return nil, .NotFound } // Copy the data and free RocksDB's buffer result := make([]byte, value_len, context.allocator) copy(result, value_ptr[:value_len]) rocksdb_free(rawptr(value_ptr)) // Cast it here and now so we don't deal with issues from FFI down the line return result, .None } // Delete key db_delete :: proc(db: ^DB, key: []byte) -> Error { err: cstring rocksdb_delete( db.handle, db.write_options, raw_data(key), c.size_t(len(key)), &err, ) if err != nil { defer rocksdb_free(rawptr(err)) // Cast it here and now so we don't deal with issues from FFI down the line return .DeleteFailed } return .None } // Flush database db_flush :: proc(db: ^DB) -> Error { flush_opts := rocksdb_flushoptions_create() if flush_opts == nil { return .Unknown } defer rocksdb_flushoptions_destroy(flush_opts) err: cstring rocksdb_flush(db.handle, flush_opts, &err) if err != nil { defer rocksdb_free(rawptr(err)) // Cast it here and now so we don't deal with issues from FFI down the line return .IOError } return .None } // Write batch WriteBatch :: struct { handle: RocksDB_WriteBatch, } // Create write batch batch_create :: proc() -> (WriteBatch, Error) { handle := rocksdb_writebatch_create() if handle == nil { return {}, .Unknown } return WriteBatch{handle = handle}, .None } // Destroy write batch batch_destroy :: proc(batch: ^WriteBatch) { rocksdb_writebatch_destroy(batch.handle) } // Add put operation to batch batch_put :: proc(batch: ^WriteBatch, key: []byte, value: []byte) { rocksdb_writebatch_put( batch.handle, raw_data(key), c.size_t(len(key)), raw_data(value), c.size_t(len(value)), ) } // Add delete operation to batch batch_delete :: proc(batch: ^WriteBatch, key: []byte) { rocksdb_writebatch_delete( batch.handle, raw_data(key), c.size_t(len(key)), ) } // Clear batch batch_clear :: proc(batch: ^WriteBatch) { rocksdb_writebatch_clear(batch.handle) } // Write batch to database batch_write :: proc(db: ^DB, batch: ^WriteBatch) -> Error { err: cstring rocksdb_write(db.handle, db.write_options, batch.handle, &err) if err != nil { defer rocksdb_free(rawptr(err)) // Cast it here and now so we don't deal with issues from FFI down the line return .WriteFailed } return .None } // Iterator Iterator :: struct { handle: RocksDB_Iterator, } // Create iterator iter_create :: proc(db: ^DB) -> (Iterator, Error) { handle := rocksdb_create_iterator(db.handle, db.read_options) if handle == nil { return {}, .Unknown } return Iterator{handle = handle}, .None } // Destroy iterator iter_destroy :: proc(iter: ^Iterator) { rocksdb_iter_destroy(iter.handle) } // Seek to first iter_seek_to_first :: proc(iter: ^Iterator) { rocksdb_iter_seek_to_first(iter.handle) } // Seek to last iter_seek_to_last :: proc(iter: ^Iterator) { rocksdb_iter_seek_to_last(iter.handle) } // Seek to key iter_seek :: proc(iter: ^Iterator, target: []byte) { rocksdb_iter_seek(iter.handle, raw_data(target), c.size_t(len(target))) } // Check if iterator is valid iter_valid :: proc(iter: ^Iterator) -> bool { return rocksdb_iter_valid(iter.handle) != 0 } // Move to next iter_next :: proc(iter: ^Iterator) { rocksdb_iter_next(iter.handle) } // Move to previous iter_prev :: proc(iter: ^Iterator) { rocksdb_iter_prev(iter.handle) } // Get current key (returns borrowed slice) iter_key :: proc(iter: ^Iterator) -> []byte { klen: c.size_t key_ptr := rocksdb_iter_key(iter.handle, &klen) if key_ptr == nil { return nil } return key_ptr[:klen] } // Get current value (returns borrowed slice) iter_value :: proc(iter: ^Iterator) -> []byte { vlen: c.size_t value_ptr := rocksdb_iter_value(iter.handle, &vlen) if value_ptr == nil { return nil } return value_ptr[:vlen] } ================================================================================ FILE: ./rocksdb_shim/rocksdb_shim.cc ================================================================================ // TODO: In order to use RocksDB's WAL replication helpers, we need to import the C++ library so we use this shim /** C++ shim implementation notes (the important bits) In this rocksdb_shim.cc we'll need to use: rocksdb::DB::Open(...) db->GetLatestSequenceNumber() db->GetUpdatesSince(seq, &iter) from each TransactionLogIterator entry: get WriteBatch and serialize via WriteBatch::Data() apply via rocksdb::WriteBatch wb(data); db->Write(write_options, &wb); Also we must configure WAL retention so the followers don’t fall off the end. RocksDB warns the iterator can become invalid if WAL is cleared aggressively; typical controls are WAL TTL / size limit. https://github.com/facebook/rocksdb/issues/1565 */ ================================================================================ FILE: ./rocksdb_shim/rocksdb_shim.h ================================================================================ // In order to use RocksDB's WAL replication helpers, we need to import the C++ library so we use this shim #pragma once #include #include #ifdef __cplusplus extern "C" { #endif typedef struct jormun_db jormun_db; typedef struct jormun_wal_iter jormun_wal_iter; // Open/close (so Odin never touches rocksdb_t directly) jormun_db *jormun_db_open(const char *path, int create_if_missing, char **err); void jormun_db_close(jormun_db *db); // Basic ops (you can mirror what you already have) void jormun_db_put(jormun_db *db, const void *key, size_t keylen, const void *val, size_t vallen, char **err); unsigned char *jormun_db_get(jormun_db *db, const void *key, size_t keylen, size_t *vallen, char **err); // caller frees with this: void jormun_free(void *p); // Replication primitives uint64_t jormun_latest_sequence(jormun_db *db); // Iterator: start at seq (inclusive-ish; RocksDB positions to batch containing seq or first after) jormun_wal_iter *jormun_wal_iter_create(jormun_db *db, uint64_t seq, char **err); void jormun_wal_iter_destroy(jormun_wal_iter *it); // Next batch -> returns 1 if produced a batch, 0 if no more / not available // You get a serialized “write batch” blob (rocksdb::WriteBatch::Data()) plus the batch start seq. int jormun_wal_iter_next(jormun_wal_iter *it, uint64_t *batch_start_seq, unsigned char **out_data, size_t *out_len, char **err); // Apply serialized writebatch blob on follower void jormun_apply_writebatch(jormun_db *db, const unsigned char *data, size_t len, char **err); #ifdef __cplusplus } #endif ================================================================================ FILE: ./TODO.md ================================================================================ # JormunDB Implementation TODO This tracks the rewrite from Zig to Odin and remaining features. ## ✅ Completed - [x] Project structure - [x] Makefile with build/run/test targets - [x] README with usage instructions - [x] ARCHITECTURE documentation - [x] RocksDB FFI bindings (rocksdb/rocksdb.odin) - [x] Core types (dynamodb/types.odin) - [x] Key codec with varint encoding (key_codec/key_codec.odin) - [x] Main entry point with arena pattern demo - [x] .gitignore - [x] HTTP Server Scaffolding ## 🚧 In Progress (Need to Complete) ### Core Modules - [ ] **dynamodb/json.odin** - DynamoDB JSON parsing and serialization - Parse `{"S": "value"}` format - Serialize AttributeValue to DynamoDB JSON - Parse request bodies (PutItem, GetItem, etc.) - [ ] **item_codec/item_codec.odin** - Binary TLV encoding for items - Encode Item to binary TLV format - Decode binary TLV back to Item - Type tag handling for all DynamoDB types - [ ] **dynamodb/storage.odin** - Storage engine with RocksDB - Table metadata management - create_table, delete_table, describe_table, list_tables - put_item, get_item, delete_item - scan, query with pagination - Table-level RW locks - [ ] **dynamodb/handler.odin** - HTTP request handlers - Route X-Amz-Target to handler functions - handle_create_table, handle_put_item, etc. - Build responses with proper error handling - Arena allocator integration ### HTTP Server - [ ] **HTTP server implementation** - Accept TCP connections - Parse HTTP POST requests - Read JSON bodies - Send HTTP responses with headers - Keep-alive support - Options (Why we haven't checked this off yet, we need to make sure we chose the right option as the project grows, might make more sense to impliment different option): - Use `core:net` directly - Use C FFI with libmicrohttpd - Use Odin's vendor:microui (if suitable) ### Expression Parsers (Priority 3) - [ ] **KeyConditionExpression parser** - Tokenizer for expressions - Parse `pk = :pk AND sk > :sk` - Support begins_with, BETWEEN - ExpressionAttributeNames/Values - [ ] **UpdateExpression parser** (later) - SET operations - REMOVE operations - ADD operations - DELETE operations ### Replication Support (Priority 4) - [ ] **Build C++ Shim in order to use RocksDB's WAL replication helpers** - [ ] **Add configurator to set instance as a master or slave node and point to proper Target and Destination IPs** - [ ] **Leverage C++ helpers from shim** ### Subscribe To Changes Feature (Priority LAST [But keep in mind because semantics we decide now will make this easier later]) - [ ] **Best-effort notifications (Postgres-ish LISTEN/NOTIFY [in-memory pub/sub fanout. If you’re not connected, you miss it.])** - Add an in-process “event bus” channels: table-wide, partition-key, item-key, “all”. - When putItem/deleteItem/updateItem/createTable/... commits successfully publish {op, table, key, timestamp, item?} - [ ] **Durable change streams (Mongo-ish [append every mutation to a persistent log and let consumers read it with resume tokens.])** - Create a “changelog” keyspace - Generate a monotonically increasing sequence by using a stable Per-partition sequence cursor - Expose via an API (I prefer publishing to MQTT or SSE) ## 📋 Testing - [ ] Unit tests for key_codec - [ ] Unit tests for item_codec - [ ] Unit tests for JSON parsing - [ ] Integration tests with AWS CLI - [ ] Benchmark suite ## 🔧 Build & Tooling - [ ] Verify Makefile works on macOS - [ ] Verify Makefile works on Linux - [ ] Add Docker support (optional) - [ ] Add install script ## 📚 Documentation - [ ] Code comments for public APIs - [ ] Usage examples in README - [ ] API compatibility matrix - [ ] Performance tuning guide ## 🎯 Priority Order 1. **HTTP Server** - Need this to accept requests 2. **JSON Parsing** - Need this to understand DynamoDB format 3. **Storage Engine** - Core CRUD operations 4. **Handlers** - Wire everything together 5. **Item Codec** - Efficient binary storage 6. **Expression Parsers** - Query functionality ## 📝 Notes ### Zig → Odin Translation Patterns **Memory Management:** ```zig // Zig const item = try allocator.create(Item); defer allocator.destroy(item); ``` ```odin // Odin item := new(Item) // No defer needed if using arena ``` **Error Handling:** ```zig // Zig fn foo() !Result { return error.Failed; } const x = try foo(); ``` ```odin // Odin foo :: proc() -> (Result, bool) { return {}, false } x := foo() or_return ``` **Slices:** ```zig // Zig const slice: []const u8 = data; ``` ```odin // Odin slice: []byte = data ``` **Maps:** ```zig // Zig var map = std.StringHashMap(Value).init(allocator); defer map.deinit(); ``` ```odin // Odin map := make(map[string]Value) defer delete(map) ``` ### Key Decisions 1. **Use `Maybe(T)` instead of `?T`** - Odin's optional type 2. **Use `or_return` instead of `try`** - Odin's error propagation 3. **Use `context.allocator`** - Implicit allocator from context 4. **Use `#partial switch`** - For union type checking 5. **Use `transmute`** - For zero-cost type conversions ### Reference Zig Files When implementing, reference these Zig files: - `src/dynamodb/json.zig` - 400 lines, DynamoDB JSON format - `src/dynamodb/storage.zig` - 460 lines, storage engine - `src/dynamodb/handler.zig` - 500+ lines, request handlers - `src/item_codec.zig` - 350 lines, TLV encoding - `src/http.zig` - 250 lines, HTTP server ### Quick Test Commands ```bash # Build and test make build make test # Run server make run # Test with AWS CLI aws dynamodb list-tables --endpoint-url http://localhost:8002 ```