A stream reads a queue and writes its results to another. The partition is the key, so each customer gets its own windows. Streams are in the JavaScript, Python, Go and Rust clients; PHP and C++ have none, and a stream reads any queue, whoever pushes to it. Like a new group it starts at the tail: push a few more orders and a result arrives a minute later.
import { Stream } from 'queen-mq'
const revenue = await Stream
.from(queen.queue('orders')) // one window per partition: per customer
.windowTumbling({ seconds: 60 })
.aggregate({ count: () => 1, sum: (order) => order.total })
.to(queen.queue('revenue-per-minute')) // one result per customer per minute
.run({ queryId: 'revenue-per-minute', url: 'http://localhost:6632' })from queen import Stream
revenue = await (
Stream.from_(queen.queue("orders")) # one window per partition: per customer
.window_tumbling(seconds=60)
.aggregate({"count": lambda order: 1, "sum": lambda order: order["total"]})
.to(queen.queue("revenue-per-minute")) # one result per customer per minute
.run(query_id="revenue-per-minute", url="http://localhost:6632")
)import "github.com/smartpricing/queen/clients/client-go/v2/streams"
revenue, err := streams.
From(client.Queue("orders").AsStreamSource()). // one window per partition: per customer
WindowTumbling(60).
Aggregate(map[string]streams.ExtractorFn{
"count": func(order interface{}) (float64, error) { return 1, nil },
"sum": func(order interface{}) (float64, error) {
return order.(map[string]interface{})["total"].(float64), nil
},
}, "count", "sum").
To(client.Queue("revenue-per-minute")). // one result per customer per minute
Run(ctx, streams.RunOptions{QueryID: "revenue-per-minute", URL: "http://localhost:6632"})
if err != nil {
log.Fatal(err)
}use queen_mq::streams::{RunOptions, Stream};
let revenue = Stream::from(queen.queue("orders")) // one window per partition: per customer
.window_tumbling(60)
.aggregate_count("count")
.aggregate_sum("sum", |order| order.number("total"))
.to(queen.queue("revenue-per-minute")) // one result per customer per minute
.run(&queen, RunOptions::new("revenue-per-minute"))
.await?;PHP has no streams client. A stream reads any queue, whoever pushes to it, so run this step from JavaScript, Python, Go or Rust.
C++ has no streams client. A stream reads any queue, whoever pushes to it, so run this step from JavaScript, Python, Go or Rust.
A stream reads its queue as a consumer group (streams.<queryId>) and keeps its state, the open windows, in the broker. Each time it closes windows, it writes the new state, pushes the results and acks the messages they came from in one transaction.
Four kinds of window
| Window | Example | What it is, and when to use it |
|---|---|---|
windowTumbling |
seconds: 60 |
Fixed windows, one after the other. Revenue per minute. |
windowSliding |
size: 600, slide: 60 |
Overlapping windows: the last 10 minutes, every minute. Each event counts in several windows, so it keeps more state. |
windowSession |
gap: 1800 |
One window per burst of activity, closed after a quiet gap. A visit, a conversation. |
windowCron |
every: 'hour' |
Windows aligned to the clock (second, minute, hour, day, week). Hourly and daily reports. |
const loginRate = await Stream
.from(queen.queue('logins')) // one partition per user
.windowSliding({ size: 600, slide: 60 }) // the last 10 minutes, every minute
.aggregate({ count: () => 1 })
.to(queen.queue('login-rate'))
.run({ queryId: 'login-rate', url: 'http://localhost:6632' })login_rate = await (
Stream.from_(queen.queue("logins")) # one partition per user
.window_sliding(size=600, slide=60) # the last 10 minutes, every minute
.aggregate({"count": lambda login: 1})
.to(queen.queue("login-rate"))
.run(query_id="login-rate", url="http://localhost:6632")
)loginRate, err := streams.
From(client.Queue("logins").AsStreamSource()). // one partition per user
WindowSliding(600, 60). // the last 10 minutes, every minute
Aggregate(map[string]streams.ExtractorFn{
"count": func(login interface{}) (float64, error) { return 1, nil },
}, "count").
To(client.Queue("login-rate")).
Run(ctx, streams.RunOptions{QueryID: "login-rate", URL: "http://localhost:6632"})
if err != nil {
log.Fatal(err)
}let login_rate = Stream::from(queen.queue("logins")) // one partition per user
.window_sliding(600, 60) // the last 10 minutes, every minute
.aggregate_count("count")
.to(queen.queue("login-rate"))
.run(&queen, RunOptions::new("login-rate"))
.await?;PHP has no streams client. A stream reads any queue, whoever pushes to it, so run this step from JavaScript, Python, Go or Rust.
C++ has no streams client. A stream reads any queue, whoever pushes to it, so run this step from JavaScript, Python, Go or Rust.
const visits = await Stream
.from(queen.queue('clicks')) // one partition per visitor
.windowSession({ gap: 1800 }) // a visit ends after 30 minutes of quiet
.aggregate({ count: () => 1 })
.foreach(async (visit) => console.log('visit ended', visit)) // acked after this returns
.run({ queryId: 'visits', url: 'http://localhost:6632' })visits = await (
Stream.from_(queen.queue("clicks")) # one partition per visitor
.window_session(gap=1800) # a visit ends after 30 minutes of quiet
.aggregate({"count": lambda click: 1})
.foreach(lambda visit: print("visit ended", visit)) # acked after this returns
.run(query_id="visits", url="http://localhost:6632")
)visits, err := streams.
From(client.Queue("clicks").AsStreamSource()). // one partition per visitor
WindowSession(1800). // a visit ends after 30 minutes of quiet
Aggregate(map[string]streams.ExtractorFn{
"count": func(click interface{}) (float64, error) { return 1, nil },
}, "count").
Foreach(func(visit interface{}, _ streams.EmitCtx) error {
fmt.Println("visit ended", visit)
return nil // acked after this returns
}).
Run(ctx, streams.RunOptions{QueryID: "visits", URL: "http://localhost:6632"})
if err != nil {
log.Fatal(err)
}let visits = Stream::from(queen.queue("clicks")) // one partition per visitor
.window_session(1800) // a visit ends after 30 minutes of quiet
.aggregate_count("count")
.foreach(|visit, _| async move {
println!("visit ended {visit}");
Ok(())
}) // acked after this returns
.run(&queen, RunOptions::new("visits"))
.await?;PHP has no streams client. A stream reads any queue, whoever pushes to it, so run this step from JavaScript, Python, Go or Rust.
C++ has no streams client. A stream reads any queue, whoever pushes to it, so run this step from JavaScript, Python, Go or Rust.
When it happened, or when it arrived
By default a window holds the events that arrived during it. Give eventTime a function that reads the time from the message, and windows hold the events that happened during it instead. It gets the whole message, so the event’s own fields are under data; the aggregate’s functions get the event itself. Go and Rust want the time as epoch milliseconds.
const hourly = await Stream
.from(queen.queue('sales'))
.windowCron({
every: 'hour', // on the clock: 10:00 to 11:00, 11:00 to 12:00
eventTime: (msg) => msg.data.soldAt, // when the sale happened, not when it arrived
allowedLateness: 120, // a sale up to 2 minutes late still counts
})
.aggregate({ count: () => 1, sum: (sale) => sale.amount, max: (sale) => sale.amount })
.to(queen.queue('sales-per-hour'))
.run({ queryId: 'sales-per-hour', url: 'http://localhost:6632', subscriptionMode: 'all' })hourly = await (
Stream.from_(queen.queue("sales"))
.window_cron(
every="hour", # on the clock: 10:00 to 11:00, 11:00 to 12:00
event_time=lambda msg: msg["data"]["soldAt"], # when it happened, not on arrival
allowed_lateness=120, # a sale up to 2 minutes late still counts
)
.aggregate({
"count": lambda sale: 1,
"sum": lambda sale: sale["amount"],
"max": lambda sale: sale["amount"],
})
.to(queen.queue("sales-per-hour"))
.run(query_id="sales-per-hour", url="http://localhost:6632", subscription_mode="all")
)hourly, err := streams.
From(client.Queue("sales").AsStreamSource()).
WindowCron("hour", // on the clock: 10:00 to 11:00, 11:00 to 12:00
streams.WithEventTime(func(msg interface{}) (int64, error) {
// when the sale happened, not when it arrived
// (eventTime gets the whole message: the sale is its data)
sale := msg.(map[string]interface{})["data"].(map[string]interface{})
at, err := time.Parse(time.RFC3339, sale["soldAt"].(string))
return at.UnixMilli(), err
}),
streams.WithAllowedLateness(120), // a sale up to 2 minutes late still counts
).
Aggregate(map[string]streams.ExtractorFn{
"count": func(sale interface{}) (float64, error) { return 1, nil },
"sum": func(sale interface{}) (float64, error) {
return sale.(map[string]interface{})["amount"].(float64), nil
},
"max": func(sale interface{}) (float64, error) {
return sale.(map[string]interface{})["amount"].(float64), nil
},
}, "count", "sum", "max").
To(client.Queue("sales-per-hour")).
Run(ctx, streams.RunOptions{QueryID: "sales-per-hour", URL: "http://localhost:6632",
SubscriptionMode: queen.SubscriptionModeAll})
if err != nil {
log.Fatal(err)
}use queen_mq::streams::ops::parse_iso_ms;
use queen_mq::streams::Every;
let hourly = Stream::from(queen.queue("sales"))
.window_cron(Every::Hour) // on the clock: 10:00 to 11:00, 11:00 to 12:00
// when the sale happened, not when it arrived
.event_time(|sale| parse_iso_ms(sale.data["soldAt"].as_str()?))
.allowed_lateness(120) // a sale up to 2 minutes late still counts
.aggregate_count("count")
.aggregate_sum("sum", |sale| sale.number("amount"))
.aggregate_max("max", |sale| sale.number("amount"))
.to(queen.queue("sales-per-hour"))
.run(&queen, RunOptions::new("sales-per-hour").subscription_mode(SubscriptionMode::All))
.await?;PHP has no streams client. A stream reads any queue, whoever pushes to it, so run this step from JavaScript, Python, Go or Rust.
C++ has no streams client. A stream reads any queue, whoever pushes to it, so run this step from JavaScript, Python, Go or Rust.
allowedLateness says how far behind the newest event an event may be and still count. A later one is dropped, or with onLate: 'include' it opens a fresh window, so the window is sent a second time with only the late events. gracePeriod keeps a window open a little after its end, and idleFlushMs (5 seconds) closes the windows of partitions that went quiet. subscriptionMode: 'all' makes a new stream read what the queue already holds.
Shaping the data
map(fn)changes each message,filter(fn)drops the ones you do not want, andflatMap(fn)turns one message into several values.aggregate({ ... })folds a window into named numbers. The name decides the fold:count,sum,min,maxandavg; any other name adds up.reduce(fn, initial)is the general form, for anything else.keyBy(fn)groups by something other than the partition (per page instead of per user). Several workers can then write the same key, which slows them down; when that matters, push to a queue partitioned by the new key first, and stream from it.
Two ways to end a stream
.to(queue) pushes each result to another queue, in the same commit as the state and the acks, into the partition of the same name unless you pass { partition }. .foreach(fn) calls your function for each result, and acks only after it returns: a crash in the middle calls it again.
foreach makes it at least once and says so. For an external effect that must happen once, send the result to a queue and let a worker do it with an idempotency key.Rate limits with a gate
gate(fn) decides, message by message, whether a message may go on now, with state kept per partition. Two ready-made gates are included: tokenBucketGate and slidingWindowGate. Rust has no ready-made gates yet, so its code writes the same token bucket as a function, with the same state.
import { tokenBucketGate } from 'queen-mq'
const limiter = await Stream
.from(queen.queue('outgoing-sms')) // one partition per customer
.gate(tokenBucketGate({ capacity: 10, refillPerSec: 1 })) // 10 at once, then 1 a second
.foreach(async (sms) => sendSms(sms))
.run({ queryId: 'sms-limiter', url: 'http://localhost:6632' })from queen import token_bucket_gate
limiter = await (
Stream.from_(queen.queue("outgoing-sms")) # one partition per customer
.gate(token_bucket_gate(capacity=10, refill_per_sec=1)) # 10 at once, then 1 a second
.foreach(send_sms)
.run(query_id="sms-limiter", url="http://localhost:6632")
)import "github.com/smartpricing/queen/clients/client-go/v2/streams/helpers"
limiter, err := streams.
From(client.Queue("outgoing-sms").AsStreamSource()). // one partition per customer
Gate(helpers.TokenBucketGate(helpers.TokenBucketGateOptions{
Capacity: 10, RefillPerSec: 1, // 10 at once, then 1 a second
})).
Foreach(func(sms interface{}, _ streams.EmitCtx) error { return sendSms(sms) }).
Run(ctx, streams.RunOptions{QueryID: "sms-limiter", URL: "http://localhost:6632"})
if err != nil {
log.Fatal(err)
}use queen_mq::streams::{GateCtx, Record};
// The Rust client has no tokenBucketGate helper; this is the same bucket. Its
// state is kept per partition in the broker, and saved only when a message passes.
fn token_bucket_gate(
capacity: f64,
refill_per_sec: f64,
) -> impl Fn(&Record, &mut GateCtx<'_>) -> bool + Send + Sync + 'static {
move |_msg, ctx| {
let now = ctx.stream_time_ms as f64;
let elapsed = ((now - ctx.num("lastRefillAt", now)) / 1000.0).max(0.0);
let tokens = (ctx.num("tokens", capacity) + elapsed * refill_per_sec).min(capacity);
ctx.set_num("lastRefillAt", now);
if tokens < 1.0 {
return false; // refused: it comes back, in order, when its lease ends
}
ctx.set_num("tokens", tokens - 1.0);
true
}
}
let limiter = Stream::from(queen.queue("outgoing-sms")) // one partition per customer
.gate(token_bucket_gate(10.0, 1.0)) // 10 at once, then 1 a second
.foreach(|sms, _| async move { send_sms(sms).await })
.run(&queen, RunOptions::new("sms-limiter"))
.await?;PHP has no streams client. A stream reads any queue, whoever pushes to it, so run this step from JavaScript, Python, Go or Rust.
C++ has no streams client. A stream reads any queue, whoever pushes to it, so run this step from JavaScript, Python, Go or Rust.
A message the gate refuses stops its partition there: it and the ones after it are delivered again, in order, when the lease runs out.
Running a stream
run() takes the stream’s queryId, its identity: its state is stored under it, so a process restarted with the same id carries on with the same windows, and several processes with the same id share the partitions between them. batchSize (200) and maxPartitions (4) size each cycle, and stop() on the handle run() returns stops it cleanly.
Push, consume, ack, replay, fetch, transactions, KV, timers, locks, streams and ephemeral queues all combine. Build on them and Queen carries the logic you would otherwise write around a broker. Patterns shows them combined, one real case each.
See also
- Windowed aggregation: a session window per trace.
- Rate limiting: a token bucket gate per customer.