Log
Everything starts as a message.
An event or command is still a message. The log records it once, then services can read it live, resume from an offset, or replay it from the beginning.
Built for
Event-driven services, audit trails, live feeds
How it works
The hierarchy is stream, topic, then partition. A stream groups related topics. A topic is a named log with no SDK-imposed schema. Partitions are the ordered logs that store its messages.
One connection can address every stream on the server. The full path is laser.stream("shop").topic("orders"). Set a default stream at connection time to omit .stream(..). See Connect.
Partitions decide ordering and parallelism. A topic splits into partitions - independent, strictly ordered logs. Order holds within a partition only, never across the whole topic. Three ways to route a published message:
- Balanced (default) - Iggy spreads messages across partitions for throughput. No ordering guarantee beyond what lands in the same partition by chance.
- Key - hash a non-empty key to one partition, so every message with the same key keeps its order relative to each other.
- Partition - target one partition number directly.
More partitions raises the ceiling on parallel consumption. Fewer partitions raises the ceiling on ordering. Most services want per-key ordering - one partition per customer or conversation - rather than either extreme.
Payloads are bytes. The raw primitive assumes no encoding. JSON, MessagePack, and Avro are convenience encoders. The examples use JSON because it is concise.
Reading it back is two different jobs:
- A named, resumable cursor. It decodes into your type and tracks per-partition offsets on the reader handle. Persist
offsets()and restore them withfrom_offsets(saved)after a restart. A fresh reader without restored offsets starts at the beginning. The quick example uses this path. - A live consumer. A single reader, or a consumer group splitting partitions across several processes (the server assigns partitions, one per member, and rebalances when members join or leave). Returns a stream you poll continuously. This is the production read path.
Delivery is at least once. A crash after delivery but before offset commit causes redelivery. Handlers must be idempotent. Each consumer chooses when to store offsets through its commit policy. See Consumers, offsets, and commit policies.
Quick example
const topic = laser.stream("shop").topic("orders")
await topic.ensure(2)
for (const order of ORDERS) {
await topic.publish().json(order).send()
}
const replay = await topic
.json(ORDER_CODEC)
.records("log-example")
for (const result of await replay.poll()) {
if (result.kind === "record") {
console.log(result.record.value.total)
}
}
// One poll reads at most one configured batch per partition, so a real
// reader drains until it reaches the current tail.let topic = laser.stream("shop").topic("orders");
topic.ensure(2).await?;
for order in [Order { id: 1, total: 99 }, Order { id: 2, total: 42 }] {
topic.publish().json(&order)?.send().await?;
}
let mut replay = topic.json::<Order>().records("log-example")?;
while let Some(next) = replay.next().await {
println!("{}", next?.value.total);
}topic = laser.stream("shop").topic("orders", cls=Order)
await topic.ensure(partitions=2)
for order in (Order(id=1, total=99), Order(id=2, total=42)):
await topic.publish(order).send()
reader = topic.records("log-example")
while (record := await reader.next()) is not None:
print(record.value.total)The runnable examples define Order, ORDERS, and TypeScript's ORDER_CODEC. Production workloads should use a tuned producer and a consumer group with a commit policy.
Full runnable example: Rust · Python · TypeScript
Producing at volume
publish() is the one-shot path: build one message, send it, await the result. Past a demo you pick one of three heavier write paths, in increasing order of throughput:
publish_batch()accumulates several messages client-side and sends them in one round trip. No background machinery, you control exactly when the batch goes.topic.producer()builds a long-lived, tuned producer. It coalesces sends into server batches and retries failures for you. This is the default production write path.- Background mode (Rust only) switches the producer to Apache Iggy's buffered, sharded send pipeline for maximum throughput.
The producer builder's full surface, with defaults:
| Option | Default | What it does |
|---|---|---|
batch_length(n) | 1000 | messages per server batch |
linger(d) | 0 | how long an incomplete batch may wait before flushing anyway |
retries(n, interval) | 3, 1s | resend attempts on failure, None retries forever |
routing(r) | Balanced | default routing for every send: Balanced, Routing::key(k), or a fixed partition |
create_stream(bool) / create_topic(bool) | true | create missing stream/topic on init, turn off to fail fast against a typo |
partitions(n) | 1 | partition count used when the producer creates the topic |
replication_factor(n) | server default | replica count when creating the topic |
expire_after(d) / never_expire() | server default | message retention when creating the topic |
max_topic_bytes(n) / unlimited_topic_size() | server default | size cap when creating the topic |
A tuned producer, from the native-streaming deep dive:
await using producer = topic.producer({
retries: 3,
retryIntervalMs: 1_000
})
await producer.send(utf8("message-0"), {
key: utf8("account-42"),
headers: { type: HeaderValue.uint16(7) }
})
await producer.sendBatch(payloads)let producer = topic
.producer()
.batch_length(1_000)
.linger(Duration::from_millis(5))
.retries(Some(3), Some(Duration::from_secs(1)))
.routing(Routing::Balanced)
.build()
.await?;
producer
.send_keyed(
ProducerMessage::new(b"message-0".as_slice())
.header(HeaderKey::try_from("type")?, HeaderValue::from(7_u16)),
b"account-42".to_vec(),
)
.await?;
producer.send_batch(batch).await?;producer = topic.producer(
batch_length=1000,
linger_ms=5,
retries=3,
retry_interval_ms=1000,
)
await producer.init()
await producer.send(
b"message-0",
headers={"type": ("uint16", 7)},
key=b"account-42",
)
await producer.send_batch(values)Per-send routing overrides the builder default. send_keyed(msg, key) and send_to_partition(msg, n) in Rust, key= / partition= on Python's send, { key } / { partition } on TypeScript's send. A key and an explicit partition are mutually exclusive on one send.
Client differences:
- Rust background mode uses Apache Iggy's buffered and sharded pipeline.
BackgroundConfigcontrols batching, shards, and backpressure.OrderedShardingpreserves order.BalancedShardingfavors throughput. Callshutdown()to flush buffered messages. - Rust explicit batching is available through
topic.batching(). It providesflush()andclose()without a background task. Its defaults are 512 records, 1 MiB, and 5 ms. - Python mirrors Rust's direct mode as keyword arguments (
batch_length,linger_ms,retries,retry_interval_ms, plus the same create/partitions/expiry/size knobs). Callawait producer.init()once before sending. No background mode. - TypeScript's producer takes
routing,retries, andretryIntervalMsonly. There is no client-sidebatchLength/lingeron the TS producer today, coalesce round trips withpublishBatch()/sendBatch(payloads)instead.
Consumers, offsets, and commit policies
topic.consumer(name, partition) pins one named reader to one partition. topic.consumer_group(group) joins a named group. The server assigns one member to each partition and rebalances when membership changes.
Groups are created and joined automatically by default through create_group(true) and auto_join_group(true).
Offsets live on the server for each consumer name or group. A restarted process reconnects with the same name and resumes from its stored offset.
start_at(..)sets the initial position when no relevant offset exists. Rust acceptsFirst,Last,Next,Offset(n), orTimestampMicros(t). TypeScript uses{ kind: "first" | "last" | "next" | "offset" | "timestamp" }. Python usespolling="first" | "last" | "next"withoffset=ortimestamp_micros=.allow_replay()permits a read at or below an existing stored offset. Set it when re-reading a group fromFirst. Without it, the consumer skips records it has already consumed.commit(message)stores a handled record's offset explicitly.store_offset(offset, partition)anddelete_offset(partition)are the raw forms,last_consumed_offset(p)/last_stored_offset(p)read the local bookkeeping back.
The commit policy decides when offsets are stored automatically. The full Rust enum, all server-side stores:
| Policy | Stores the offset |
|---|---|
Polling (default) | previous batch's offset, just before polling again |
All | after consuming everything a poll returned |
Each | after every yielded record |
Every(n) | after every n yielded records |
Interval(d) | on a timer |
IntervalOrPolling(d) / IntervalOrAll(d) / IntervalOrEach(d) | the interval or the event, whichever fires first |
Disabled | never, you call commit(message) yourself |
Every automatic policy trades a wider redelivery window for fewer round trips. Disabled plus commit-after-handled is crash-safe to the exact message and costs one store_offset round trip per message, measurably slower on a real network, by design. Both patterns, from the same deep dive:
await using consumer = await topic.consumerGroup("workers", {
batchLength: 100,
autoCommit: false,
startFrom: { kind: "first" },
pollIntervalMs: 5
})
for (;;) {
const message = await consumer.nextWithin(5_000, { signal })
if (message === null) break
handle(message)
await consumer.commit(message)
}let mut consumer = topic
.consumer_group("workers")
.batch_length(100)
.poll_interval(Duration::from_millis(5))
.start_at(ConsumerStart::First)
.allow_replay()
.commit_policy(CommitPolicy::Disabled)
.build()
.await?;
while let Some(message) = consumer.next().await {
let message = message?;
handle(&message)?;
consumer.commit(&message).await?;
}
consumer.shutdown().await?;consumer = topic.consumer_group(
"workers",
batch_length=100,
poll_interval_ms=5,
polling="first",
auto_commit="disabled",
allow_replay=True,
)
await consumer.init()
async for message in consumer:
handle(message)
await consumer.commit(message)
await consumer.shutdown()Swap the policy for the batched production default by dropping the manual commit and setting commit_policy(CommitPolicy::IntervalOrEach(Duration::from_secs(1))) in Rust or auto_commit="each", commit_interval_ms=1000 in Python.
Client differences:
- Rust exposes the full nine-variant
CommitPolicyplusnext_within(timeout)(a typed timeout error instead of hand-rolledtokio::time::timeoutplumbing),polling_retry_interval, andinit_retries. - Python maps policies to
auto_commitstrings.commit_interval_msadds the interval variant.commit_everysets the count forevery. Callawait consumer.init()before iterating. - TypeScript uses
autoCommit: trueto store offsets on each poll. Withfalse, callconsumer.commit(message). It has no interval, each, or every policy. Its defaults arebatchLength100 andpollIntervalMs250.
Shutdown matters. shutdown() stops polling, leaves the group, and flushes final offset state under the automatic policies. Disabled preserves exactly what you last committed, nothing more.
Apache Iggy access
The wrapper never locks you out of the substrate. topic.iggy_producer(), topic.iggy_consumer(..), and topic.iggy_consumer_group(group) (Rust) hand back Apache Iggy's own builders on the same VSR connection, for any knob the Laser surface doesn't re-export.
Key operations
| Verb | What it does |
|---|---|
stream(name).topic(name) | Address a topic on any stream |
topic.ensure(partitions) | Create the topic if it doesn't exist, with a fixed partition count |
publish().payload(bytes) | Append one message, raw and unencoded |
.json(v) / .msgpack(v) / .avro(v) | Encoding convenience over .payload |
.partition_key(key) | Route by key for per-key ordering |
.index(key, value) | Stamp an explicit indexed header, so a view can query it with no projection schema |
.header(key, value) | Attach non-indexed metadata (trace id, source) that rides through to a projector's Row.metadata |
publish_batch() | Accumulate several messages into one round trip |
topic.producer() | A long-lived producer with batching, linger, retries - see Producing at volume |
topic.batching() | A client-side accumulator with explicit flush()/close() (Rust) |
topic.json::<T>().records(name) | A named, resumable cursor |
topic.consumer(..) / consumer_group(name) | A live stream, partitioned across group members |
consumer.next() / next_within(timeout) | Wait for the next record, optionally bounded by a typed timeout |
consumer.commit(message) | Explicit offset commit for commit-after-handled semantics |
store_offset(..) / delete_offset(..) | Raw server-offset management for one reader |
topic.replay() | A cursor that reads again from the start instead of tailing |
Not an exhaustive list. See Views for the full field-extraction and projection-registration mechanics .index(..) feeds into.
Running it
Log runs on Laser Stack, LaserData Cloud, and standalone VSR-enabled Iggy. Context, folded Memory, and Fabric use the same streaming path. Views, Changes, State, and Graph require laser-plane. Delivery is at least once, so consumers must handle redelivery.