window_machines 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 9587883520f31642bd8ef66526e42dfe82c863f19d5eb282d91dd01d078fe22c
4
+ data.tar.gz: c3d6d27cfc1557e9b1adf5675f5b67f6a1a2d60a201988964840d9c402962577
5
+ SHA512:
6
+ metadata.gz: b861a7b54a3e228649b94e4c21679edab4a23bfe0d0dd0d8969c04e7284f3ad5c782931588e6577b7574813374fe715d476b574af239eae82544f046780d896a
7
+ data.tar.gz: b11786fd6ce936ee32627894fcff863aa52190ce57cce53ae6442cc5919cb196d3972c84fa0953a9f3d5b30000c9be01f2ab8b22ad8123660b9df849639c93fa
data/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ ## [0.1.4](https://github.com/seuros/window_machines/compare/window_machines/v0.1.3...window_machines/v0.1.4) (2026-11-06)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * crates.io keywords and categories for the core ([b8098d7](https://github.com/seuros/window_machines/commit/b8098d7d280dfd4e48ba7ade3a72b7f891853ec4))
9
+
10
+ ## [0.1.3](https://github.com/seuros/window_machines/compare/window_machines/v0.1.2...window_machines/v0.1.3) (2026-11-06)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * keep test sources out of the packaged gem and crate ([db7ff81](https://github.com/seuros/window_machines/commit/db7ff817e11a2448c17640631c2f8a6a254447a3))
16
+
17
+ ## [0.1.2](https://github.com/seuros/window_machines/compare/window_machines/v0.1.1...window_machines/v0.1.2) (2026-11-06)
18
+
19
+
20
+ ### Bug Fixes
21
+
22
+ * cross builds no longer compile for the host ([ca5ad94](https://github.com/seuros/window_machines/commit/ca5ad94ba196793f6366a9a5025903bd5bd22c51))
23
+
24
+ ## [0.1.1](https://github.com/seuros/window_machines/compare/window_machines-v0.1.0...window_machines/v0.1.1) (2026-11-06)
25
+
26
+
27
+ ### Features
28
+
29
+ * initial extraction ([3bd753e](https://github.com/seuros/window_machines/commit/3bd753ec5ae5890e4e54272e5df4523c9d685d4b))
data/Cargo.toml ADDED
@@ -0,0 +1,21 @@
1
+ [workspace]
2
+ members = ["ext/window_machines_native/core", "ext/window_machines_native/ffi"]
3
+ default-members = ["ext/window_machines_native/core"]
4
+ resolver = "3"
5
+ [workspace.package]
6
+ edition = "2024"
7
+ rust-version = "1.99"
8
+ license = "MIT"
9
+ [workspace.dependencies]
10
+ window-machines = { path = "ext/window_machines_native/core" }
11
+ magnus = { version = "0.9.3", default-features = false }
12
+ rb-sys = { version = "0.9.130", default-features = false, features = ["stable-api-compiled-fallback"] }
13
+ [workspace.lints.rust]
14
+ unsafe_code = "deny"
15
+ unused_must_use = "warn"
16
+ [workspace.lints.clippy]
17
+ all = { level = "warn", priority = -1 }
18
+ pedantic = { level = "warn", priority = -1 }
19
+ nursery = { level = "warn", priority = -1 }
20
+ unwrap_used = "deny"
21
+ expect_used = "deny"
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Abdelkader Boudih
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,198 @@
1
+ # WindowMachines
2
+
3
+ > Bucket events by when they happened, not by when they finally showed up. 🔭
4
+
5
+ Event-time windows for Ruby. Give it a timestamp and it tells you which tumbling or sliding bucket the event belongs in. Give it a `Watermark` and it tells you when a bucket is safe to close. Give it a `Sessions` tracker and it groups bursts of activity, merging two bursts into one when a late arrival turns out to bridge the gap.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ gem 'window_machines'
11
+ ```
12
+
13
+ ```ruby
14
+ # Every event lands in a ten-minute bucket, keyed by its own timestamp.
15
+ WindowMachines.tumbling(event.at, 600) # => [start, end] of its bucket
16
+
17
+ # Nothing gets reported until the watermark says the bucket is done.
18
+ watermark = WindowMachines::Watermark.new
19
+ watermark.advance(latest_timestamp_seen)
20
+ watermark.closes?(start, finish, 120) # => true once start..finish is two minutes behind
21
+
22
+ # Activity bursts, split by five minutes of silence, merged if something fills the gap.
23
+ sessions = WindowMachines::Sessions.new(300)
24
+ sessions.insert(event.at) # => [first, last] of the session it joined
25
+ sessions.advance_watermark(latest_timestamp_seen)
26
+ sessions.take_closed # => the oldest finished session, or nil
27
+ ```
28
+
29
+ Timestamps are Integers in whatever unit you pick. Seconds, milliseconds, ticks. The gem never looks at a clock.
30
+
31
+ ## A Message to the Resistance
32
+
33
+ So you built the signups-per-ten-minutes chart. You bucket each event by `Time.now` as it comes off the queue, you flush the bucket when the clock crosses the boundary, product loves it.
34
+
35
+ Then the mobile app ships an offline queue. A user signs up on a plane at 14:03, the phone syncs at 16:40, and your chart puts the signup in the 16:40 bucket. The 14:00 bucket was flushed two hours ago and will never be corrected. Nobody notices until finance reconciles the month and the numbers don't add up.
36
+
37
+ The event knew when it happened. Your pipeline threw that away and used arrival time instead, because arrival time is what the clock on the wall gives you for free.
38
+
39
+ This gem is the other way around. The event's own timestamp decides the bucket. A watermark you advance decides when a bucket has waited long enough for stragglers. Late arrivals land in the right bucket or get rejected loudly, never quietly misfiled.
40
+
41
+ ## Chapter 1: Stardate 2025.186, the AMS Akidau
42
+
43
+ > **AMS Akidau**, NCC-4219, *Akidau*-class survey vessel.
44
+ > The order telemetry arrives is not the order the stars emitted it.
45
+
46
+ Light from a near star reaches the survey deck in minutes. Light from a far one takes years. The deck doesn't log a supernova under the day the photons hit the sensor. It logs it under the day it happened, and it waits a stated grace period before declaring any day's catalogue final.
47
+
48
+ Somewhere in the chart room, a cadet files:
49
+
50
+ ```ruby
51
+ def bucket_for(event)
52
+ (Time.now.to_i / 600) * 600 # close enough
53
+ end
54
+ ```
55
+
56
+ "That," says the survey officer, "is how a supernova ends up catalogued two years late."
57
+
58
+ ## The Flight Plan
59
+
60
+ Each window moves through three states as the watermark catches up with it:
61
+
62
+ ```mermaid
63
+ stateDiagram-v2
64
+ [*] --> open
65
+ open --> open: event with timestamp inside the window
66
+ open --> closable: watermark reaches window end plus allowed lateness
67
+ closable --> reported: you emit it and take_closed hands it over
68
+ reported --> [*]
69
+ open --> rejected: insert at or below the watermark
70
+ rejected --> [*]
71
+ ```
72
+
73
+ And the signup that flew in from a plane:
74
+
75
+ ```mermaid
76
+ sequenceDiagram
77
+ participant Q as Queue
78
+ participant W as Watermark
79
+ participant B as Bucket 14:00-14:10
80
+
81
+ Q->>B: signup at 14:03 (arrived 16:40)
82
+ Note over B: tumbling(14:03, 600) picks this bucket
83
+ Q->>W: advance(16:40)
84
+ W-->>B: closes?(14:00, 14:10, 120) => true
85
+ Note over B: safe to report, 14:03 included
86
+ ```
87
+
88
+ With the grace period set to two minutes, a bucket closes once the watermark is two minutes past its end. Any event that still claims to belong in a closed bucket is refused instead of miscounted.
89
+
90
+ ## How Windows Work
91
+
92
+ ### Tumbling
93
+
94
+ Fixed-size, back-to-back buckets. A timestamp belongs to exactly one.
95
+
96
+ ```ruby
97
+ WindowMachines.tumbling(12, 10) # => [10, 20]
98
+ WindowMachines.tumbling(19, 10) # => [10, 20]
99
+ WindowMachines.tumbling(20, 10) # => [20, 30]
100
+ ```
101
+
102
+ Windows are half-open. The start is inside, the end is not, so 20 begins the next bucket.
103
+
104
+ ### Sliding
105
+
106
+ Overlapping buckets of one width, started every `slide`. A timestamp can sit in several at once. You get them newest first.
107
+
108
+ ```ruby
109
+ WindowMachines.sliding(12, 10, 5) # => [[10, 20], [5, 15]]
110
+ WindowMachines.sliding(7, 10, 5) # => [[5, 15], [0, 10]]
111
+ ```
112
+
113
+ A slide wider than the width leaves holes. A timestamp that falls into a hole gets an empty list, not a guess:
114
+
115
+ ```ruby
116
+ WindowMachines.sliding(12, 1, 5) # => []
117
+ ```
118
+
119
+ ### Watermarks
120
+
121
+ A watermark is your claim that nothing older than this will still turn up. You advance it. It never moves backwards.
122
+
123
+ ```ruby
124
+ watermark = WindowMachines::Watermark.new
125
+ watermark.value # => nil, nothing seen yet
126
+ watermark.advance(22) # => true
127
+ watermark.advance(9) # => false, ignored
128
+ watermark.value # => 22
129
+ ```
130
+
131
+ `closes?` answers whether a window has waited long enough. It takes the window plus how much lateness you're willing to absorb:
132
+
133
+ ```ruby
134
+ watermark.closes?(10, 20, 2) # => true, 20 + 2 is not past 22
135
+ watermark.closes?(10, 20, 3) # => false, give it one more tick
136
+ watermark.closes?(20, 30, 0) # => false, still open
137
+ ```
138
+
139
+ ### Sessions
140
+
141
+ Sessions have no fixed size. A session is a run of events where no two neighbours are more than `gap` apart. Insert returns the span of whichever session the event ended up in.
142
+
143
+ ```ruby
144
+ sessions = WindowMachines::Sessions.new(5)
145
+ sessions.insert(10) # => [10, 10], a new session
146
+ sessions.insert(15) # => [10, 15], five apart, still one session
147
+ sessions.insert(30) # => [30, 30], too far, a second session
148
+ sessions.insert(21) # => [21, 21], nine from 30, a third
149
+ ```
150
+
151
+ Then the late one shows up:
152
+
153
+ ```ruby
154
+ sessions.insert(26) # => [21, 30], bridges the gap and merges two sessions into one
155
+ ```
156
+
157
+ A session is finished once the watermark is a full `gap` past its last event. `take_closed` hands you the oldest finished session and forgets it. Call it until it returns `nil`.
158
+
159
+ ```ruby
160
+ sessions.advance_watermark(25)
161
+ sessions.take_closed # => [10, 15], 15 + 5 is behind 25
162
+ sessions.take_closed # => nil, [21, 30] is still open
163
+
164
+ sessions.advance_watermark(40)
165
+ sessions.take_closed # => [21, 30]
166
+ ```
167
+
168
+ Anything at or below the watermark is late by definition and raises, leaving the sessions untouched:
169
+
170
+ ```ruby
171
+ sessions.insert(40) # => raises ArgumentError
172
+ ```
173
+
174
+ ## The Rules of the Survey
175
+
176
+ - **Everything is an Integer** in `0..2**64-1`. Floats, strings, and anything else raise `TypeError`. Negative or oversized values raise `RangeError`.
177
+ - **Zero widths, slides, and gaps are rejected** with `ArgumentError`. A bucket of size zero holds nothing.
178
+ - **Sliding is capped at 32 windows per timestamp.** A width-to-slide ratio that would produce more raises `RangeError` rather than truncating the list.
179
+ - **Sessions hold at most 32 open sessions at once.** A 33rd raises `RangeError` and nothing is dropped to make room. Close some first.
180
+ - **Late inserts raise.** An event at or below the sessions watermark is refused, not silently folded into something.
181
+ - **Frozen means frozen.** `freeze` a watermark or a sessions tracker and every mutation raises `FrozenError`.
182
+
183
+ ## What You Think You're Doing vs Reality
184
+
185
+ ### You Think: "I'll just add a small delay before flushing. Late events are rare."
186
+ ### Reality: Late is a distribution, not an edge case.
187
+
188
+ Mobile sync, retried webhooks, a consumer that fell behind for an hour. A fixed delay picks one point on that curve and misfiles everything past it. A watermark you advance from the data itself moves with the curve, and `closes?` with explicit lateness makes the trade-off a number you chose, not one you guessed.
189
+
190
+ ## What's Yours
191
+
192
+ The gem decides boundaries. It never stores a payload. Keep your own counts or rows per `[start, end]` pair, add to them when an event is bucketed, and emit them when `closes?` says so or `take_closed` returns a span. Advancing the watermark is also your call. Drive it from the timestamps you've actually seen, with whatever lag your sources deserve.
193
+
194
+ ## License
195
+
196
+ MIT License. See [LICENSE.txt](LICENSE.txt) for details.
197
+
198
+ **Remember: The signup happened at 14:03. The phone only admitted it at 16:40.**
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ ## [0.2.2](https://github.com/seuros/window_machines/compare/window-machines-v0.2.1...window-machines-v0.2.2) (2026-11-06)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * crates.io keywords and categories for the core ([b8098d7](https://github.com/seuros/window_machines/commit/b8098d7d280dfd4e48ba7ade3a72b7f891853ec4))
9
+
10
+ ## [0.2.1](https://github.com/seuros/window_machines/compare/window-machines-v0.2.0...window-machines-v0.2.1) (2026-11-06)
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * keep test sources out of the packaged gem and crate ([db7ff81](https://github.com/seuros/window_machines/commit/db7ff817e11a2448c17640631c2f8a6a254447a3))
16
+
17
+ ## [0.2.0](https://github.com/seuros/window_machines/compare/window-machines-v0.1.0...window-machines-v0.2.0) (2026-11-06)
18
+
19
+
20
+ ### Features
21
+
22
+ * initial extraction ([3bd753e](https://github.com/seuros/window_machines/commit/3bd753ec5ae5890e4e54272e5df4523c9d685d4b))
@@ -0,0 +1,13 @@
1
+ [package]
2
+ name = "window-machines"
3
+ version = "0.2.2"
4
+ edition.workspace = true
5
+ rust-version.workspace = true
6
+ license.workspace = true
7
+ description = "Bounded event-time windows, watermarks, and session merging"
8
+ readme = "README.md"
9
+ exclude = ["src/**/tests.rs", "tests/"]
10
+ keywords = ["windowing", "event-time", "watermark", "sessions", "no-std"]
11
+ categories = ["no-std", "algorithms"]
12
+ [lints]
13
+ workspace = true
@@ -0,0 +1,22 @@
1
+ # window-machines
2
+
3
+ > **AMS Akidau**, NCC-4219, *Akidau*-class survey vessel.
4
+
5
+ Engine-room flight manual: divide observation time into bounded survey windows.
6
+
7
+ Allocation-free `no_std` window boundaries and one-key bounded session tracking.
8
+ All timestamps and widths use one caller-selected unsigned unit and origin.
9
+ Tumbling/sliding windows are aligned to zero and half-open `[start, end)`.
10
+ Sliding results are newest-start-first; too-small output capacity rejects rather
11
+ than silently truncating. Overflowing boundaries reject.
12
+
13
+ Watermarks never regress. A window closes when watermark reaches its end plus
14
+ allowed lateness; arithmetic overflow means it cannot yet close.
15
+
16
+ Sessions merge events whose inactivity gaps are **at most** the configured gap,
17
+ including out-of-order bridging events. Events at/below the current watermark
18
+ are late and rejected; session allowed lateness is zero in this version. Advancing
19
+ a watermark does not discard sessions: explicitly drain `take_closed()`.
20
+ Full tables reject events that cannot merge. Callers retain aggregation payloads
21
+ and apply the same merges; the core stores boundaries only. Multi-key indexing,
22
+ retractions, and distributed watermark coordination are not implemented.
@@ -0,0 +1,85 @@
1
+ #![no_std]
2
+ #![doc = include_str!("../README.md")]
3
+ use core::num::NonZeroU64;
4
+
5
+ mod sessions;
6
+ pub use sessions::{Insert, Session, Sessions};
7
+
8
+ /// Half-open event-time interval.
9
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
10
+ pub struct Window {
11
+ /// Inclusive start.
12
+ pub start: u64,
13
+ /// Exclusive end.
14
+ pub end: u64,
15
+ }
16
+
17
+ /// Zero-aligned tumbling window containing this event.
18
+ #[must_use]
19
+ pub const fn tumbling(timestamp: u64, width: NonZeroU64) -> Option<Window> {
20
+ let start = timestamp - timestamp % width.get();
21
+ match start.checked_add(width.get()) {
22
+ Some(end) => Some(Window { start, end }),
23
+ None => None,
24
+ }
25
+ }
26
+
27
+ /// All nonnegative zero-aligned sliding windows containing this event.
28
+ #[must_use]
29
+ pub fn sliding<const N: usize>(
30
+ timestamp: u64,
31
+ size: NonZeroU64,
32
+ slide: NonZeroU64,
33
+ ) -> Option<[Option<Window>; N]> {
34
+ let mut result = [None; N];
35
+ let mut start = timestamp - timestamp % slide.get();
36
+ let mut count = 0;
37
+ loop {
38
+ let end = start.checked_add(size.get())?;
39
+ if end <= timestamp {
40
+ break;
41
+ }
42
+ if count == N {
43
+ return None;
44
+ }
45
+ result[count] = Some(Window { start, end });
46
+ count += 1;
47
+ let Some(previous) = start.checked_sub(slide.get()) else {
48
+ break;
49
+ };
50
+ start = previous;
51
+ }
52
+ Some(result)
53
+ }
54
+
55
+ /// Monotonic event-time progress, not a wall clock or consensus watermark.
56
+ #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
57
+ pub struct Watermark {
58
+ value: Option<u64>,
59
+ }
60
+ impl Watermark {
61
+ /// Advance or repeat progress; reject backwards observations.
62
+ #[must_use]
63
+ pub fn advance(&mut self, value: u64) -> bool {
64
+ if self.value.is_some_and(|old| value < old) {
65
+ return false;
66
+ }
67
+ self.value = Some(value);
68
+ true
69
+ }
70
+ /// Last observation, or `None` before initialization.
71
+ #[must_use]
72
+ pub const fn value(self) -> Option<u64> {
73
+ self.value
74
+ }
75
+ /// Whether event-time progress closes this interval plus allowed lateness.
76
+ #[must_use]
77
+ pub fn closes(self, window: Window, allowed_lateness: u64) -> bool {
78
+ window
79
+ .end
80
+ .checked_add(allowed_lateness)
81
+ .is_some_and(|end| self.value.is_some_and(|value| value >= end))
82
+ }
83
+ }
84
+ #[cfg(test)]
85
+ mod tests;
@@ -0,0 +1,129 @@
1
+ use crate::Watermark;
2
+ use core::num::NonZeroU64;
3
+
4
+ /// Boundaries of a session; both endpoints are actual included event timestamps.
5
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
6
+ pub struct Session {
7
+ /// Earliest event.
8
+ pub first: u64,
9
+ /// Latest event.
10
+ pub last: u64,
11
+ }
12
+
13
+ /// Session event admission.
14
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
15
+ #[non_exhaustive]
16
+ pub enum Insert {
17
+ /// New or merged boundaries; adapters must merge overlapping payloads too.
18
+ Accepted(Session),
19
+ /// Event is at/below the current watermark.
20
+ Late,
21
+ /// No free slot and no existing session could absorb the event.
22
+ Full,
23
+ /// Inactivity endpoint overflowed.
24
+ Overflow,
25
+ }
26
+
27
+ /// Bounded sessions for one logical event key.
28
+ #[derive(Debug)]
29
+ pub struct Sessions<const N: usize> {
30
+ entries: [Option<Session>; N],
31
+ gap: NonZeroU64,
32
+ watermark: Watermark,
33
+ }
34
+ impl<const N: usize> Sessions<N> {
35
+ /// Construct an empty one-key session tracker.
36
+ #[must_use]
37
+ pub fn new(gap: NonZeroU64) -> Self {
38
+ Self {
39
+ entries: [None; N],
40
+ gap,
41
+ watermark: Watermark::default(),
42
+ }
43
+ }
44
+
45
+ /// Merge an event atomically, including bridges across multiple sessions.
46
+ #[must_use]
47
+ pub fn insert(&mut self, timestamp: u64) -> Insert {
48
+ if self
49
+ .watermark
50
+ .value()
51
+ .is_some_and(|value| timestamp <= value)
52
+ {
53
+ return Insert::Late;
54
+ }
55
+ if timestamp.checked_add(self.gap.get()).is_none() {
56
+ return Insert::Overflow;
57
+ }
58
+ let mut candidate = Session {
59
+ first: timestamp,
60
+ last: timestamp,
61
+ };
62
+ let mut merged = [false; N];
63
+ // Fixed point handles any slot order and transitive bridges.
64
+ for _ in 0..N {
65
+ let mut changed = false;
66
+ for (index, session) in self.entries.iter().enumerate() {
67
+ let Some(session) = session else {
68
+ continue;
69
+ };
70
+ if !merged[index]
71
+ && u128::from(candidate.first)
72
+ <= u128::from(session.last) + u128::from(self.gap.get())
73
+ && u128::from(session.first)
74
+ <= u128::from(candidate.last) + u128::from(self.gap.get())
75
+ {
76
+ merged[index] = true;
77
+ candidate.first = candidate.first.min(session.first);
78
+ candidate.last = candidate.last.max(session.last);
79
+ changed = true;
80
+ }
81
+ }
82
+ if !changed {
83
+ break;
84
+ }
85
+ }
86
+ let Some(slot) = merged
87
+ .iter()
88
+ .position(|merged| *merged)
89
+ .or_else(|| self.entries.iter().position(Option::is_none))
90
+ else {
91
+ return Insert::Full;
92
+ };
93
+ for (index, merged) in merged.into_iter().enumerate() {
94
+ if merged {
95
+ self.entries[index] = None;
96
+ }
97
+ }
98
+ self.entries[slot] = Some(candidate);
99
+ Insert::Accepted(candidate)
100
+ }
101
+
102
+ /// Advance event-time progress, without silently deleting closed sessions.
103
+ #[must_use]
104
+ pub fn advance_watermark(&mut self, value: u64) -> bool {
105
+ self.watermark.advance(value)
106
+ }
107
+
108
+ /// Drain the earliest closed session; payload emission remains the caller's job.
109
+ pub fn take_closed(&mut self) -> Option<Session> {
110
+ let value = self.watermark.value()?;
111
+ let index = self
112
+ .entries
113
+ .iter()
114
+ .enumerate()
115
+ .filter_map(|(index, session)| {
116
+ session
117
+ .filter(|session| {
118
+ session
119
+ .last
120
+ .checked_add(self.gap.get())
121
+ .is_some_and(|end| value >= end)
122
+ })
123
+ .map(|session| (index, session.first))
124
+ })
125
+ .min_by_key(|&(_, first)| first)
126
+ .map(|(index, _)| index)?;
127
+ self.entries[index].take()
128
+ }
129
+ }
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ffi/extconf'
@@ -0,0 +1,20 @@
1
+ [package]
2
+ name = "window_machines_native"
3
+ version = "0.1.3"
4
+ edition.workspace = true
5
+ rust-version.workspace = true
6
+ license.workspace = true
7
+ publish = false
8
+
9
+ [lib]
10
+ crate-type = ["cdylib"]
11
+ test = false
12
+ doctest = false
13
+
14
+ [dependencies]
15
+ window-machines.workspace = true
16
+ magnus.workspace = true
17
+ rb-sys.workspace = true
18
+
19
+ [lints]
20
+ workspace = true
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mkmf'
4
+ require 'pathname'
5
+ require 'rb_sys/mkmf'
6
+ create_rust_makefile('window_machines_native/window_machines_native') do |extension|
7
+ extension.ext_dir = Pathname(__dir__).relative_path_from(Pathname(Dir.pwd)).to_s
8
+ extension.profile = ENV.fetch('RB_SYS_CARGO_PROFILE', :release).to_sym
9
+ end
10
+ # Installed source gems enter via the parent extconf, unlike rake-compiler.
11
+ makefile = File.join(Dir.pwd, 'Makefile')
12
+ File.write(makefile, File.read(makefile).gsub(
13
+ /^RB_SYS_CARGO_MANIFEST_DIR \?=.*$/,
14
+ "RB_SYS_CARGO_MANIFEST_DIR ?= #{File.expand_path(__dir__)}"
15
+ ))
@@ -0,0 +1,10 @@
1
+ use magnus::{Error, Ruby, function, prelude::*};
2
+
3
+ #[magnus::init]
4
+ fn init(ruby: &Ruby) -> Result<(), Error> {
5
+ let module = ruby.define_module("WindowMachines")?;
6
+ module.define_singleton_method("tumbling", function!(crate::tumbling, 2))?;
7
+ module.define_singleton_method("sliding", function!(crate::sliding, 3))?;
8
+ crate::watermark::define(ruby, module)?;
9
+ crate::sessions::define(ruby, module)
10
+ }
@@ -0,0 +1,46 @@
1
+ //! Thin event-time boundary adapters; no Ruby payloads are retained.
2
+ mod init;
3
+ mod sessions;
4
+ mod watermark;
5
+
6
+ use std::num::NonZeroU64;
7
+
8
+ use magnus::{Error, Integer, RArray, Ruby, TryConvert, Value};
9
+
10
+ #[derive(Clone, Copy)]
11
+ struct Tick(u64);
12
+
13
+ impl TryConvert for Tick {
14
+ fn try_convert(value: Value) -> Result<Self, Error> {
15
+ if Integer::from_value(value).is_none() {
16
+ return Err(Error::new(
17
+ Ruby::get_with(value).exception_type_error(),
18
+ "time must be an Integer in 0..2**64",
19
+ ));
20
+ }
21
+ u64::try_convert(value).map(Self)
22
+ }
23
+ }
24
+
25
+ fn positive(ruby: &Ruby, value: Tick) -> Result<NonZeroU64, Error> {
26
+ NonZeroU64::new(value.0)
27
+ .ok_or_else(|| Error::new(ruby.exception_arg_error(), "width/gap must be positive"))
28
+ }
29
+
30
+ fn tumbling(ruby: &Ruby, time: Tick, width: Tick) -> Result<(u64, u64), Error> {
31
+ let window = window_machines::tumbling(time.0, positive(ruby, width)?)
32
+ .ok_or_else(|| Error::new(ruby.exception_range_error(), "window endpoint overflow"))?;
33
+ Ok((window.start, window.end))
34
+ }
35
+
36
+ fn sliding(ruby: &Ruby, time: Tick, width: Tick, slide: Tick) -> Result<RArray, Error> {
37
+ let windows =
38
+ window_machines::sliding::<32>(time.0, positive(ruby, width)?, positive(ruby, slide)?)
39
+ .ok_or_else(|| {
40
+ Error::new(
41
+ ruby.exception_range_error(),
42
+ "endpoint overflow or more than 32 sliding windows",
43
+ )
44
+ })?;
45
+ Ok(ruby.ary_from_iter(windows.into_iter().flatten().map(|w| (w.start, w.end))))
46
+ }
@@ -0,0 +1,55 @@
1
+ use std::cell::RefCell;
2
+
3
+ use magnus::{Error, RArray, RModule, Ruby, function, method, prelude::*, typed_data::Obj};
4
+ use window_machines::{Insert, Sessions};
5
+
6
+ use crate::{Tick, positive};
7
+
8
+ #[magnus::wrap(class = "WindowMachines::Sessions", free_immediately, size)]
9
+ struct Wrapper(RefCell<Sessions<32>>);
10
+
11
+ impl Wrapper {
12
+ fn new(ruby: &Ruby, gap: Tick) -> Result<Self, Error> {
13
+ Ok(Self(RefCell::new(Sessions::new(positive(ruby, gap)?))))
14
+ }
15
+
16
+ fn insert(ruby: &Ruby, this: Obj<Self>, time: Tick) -> Result<RArray, Error> {
17
+ this.check_frozen()?;
18
+ let result = this.0.borrow_mut().insert(time.0);
19
+ // No Ruby allocation while a RefCell guard is alive.
20
+ match result {
21
+ Insert::Accepted(session) => Ok(ruby.ary_from_iter([session.first, session.last])),
22
+ Insert::Late => Err(Error::new(
23
+ ruby.exception_arg_error(),
24
+ "event at/below watermark",
25
+ )),
26
+ Insert::Full => Err(Error::new(
27
+ ruby.exception_range_error(),
28
+ "32 session capacity reached",
29
+ )),
30
+ _ => Err(Error::new(
31
+ ruby.exception_range_error(),
32
+ "session endpoint overflow",
33
+ )),
34
+ }
35
+ }
36
+
37
+ fn advance(this: Obj<Self>, time: Tick) -> Result<bool, Error> {
38
+ this.check_frozen()?;
39
+ Ok(this.0.borrow_mut().advance_watermark(time.0))
40
+ }
41
+
42
+ fn take_closed(this: Obj<Self>) -> Result<Option<(u64, u64)>, Error> {
43
+ this.check_frozen()?;
44
+ let session = this.0.borrow_mut().take_closed();
45
+ Ok(session.map(|s| (s.first, s.last)))
46
+ }
47
+ }
48
+
49
+ pub fn define(ruby: &Ruby, module: RModule) -> Result<(), Error> {
50
+ let class = module.define_class("Sessions", ruby.class_object())?;
51
+ class.define_singleton_method("new", function!(Wrapper::new, 1))?;
52
+ class.define_method("insert", method!(Wrapper::insert, 1))?;
53
+ class.define_method("advance_watermark", method!(Wrapper::advance, 1))?;
54
+ class.define_method("take_closed", method!(Wrapper::take_closed, 0))
55
+ }
@@ -0,0 +1,54 @@
1
+ use std::cell::RefCell;
2
+
3
+ use magnus::{Error, RModule, Ruby, function, method, prelude::*, typed_data::Obj};
4
+ use window_machines::{Watermark, Window};
5
+
6
+ use crate::Tick;
7
+
8
+ #[magnus::wrap(class = "WindowMachines::Watermark", free_immediately, size)]
9
+ struct Wrapper(RefCell<Watermark>);
10
+
11
+ impl Wrapper {
12
+ fn new() -> Self {
13
+ Self(RefCell::new(Watermark::default()))
14
+ }
15
+
16
+ fn advance(this: Obj<Self>, time: Tick) -> Result<bool, Error> {
17
+ this.check_frozen()?;
18
+ Ok(this.0.borrow_mut().advance(time.0))
19
+ }
20
+
21
+ fn value(&self) -> Option<u64> {
22
+ self.0.borrow().value()
23
+ }
24
+
25
+ fn closes(
26
+ ruby: &Ruby,
27
+ this: &Self,
28
+ start: Tick,
29
+ end: Tick,
30
+ lateness: Tick,
31
+ ) -> Result<bool, Error> {
32
+ if start.0 >= end.0 || end.0.checked_add(lateness.0).is_none() {
33
+ return Err(Error::new(
34
+ ruby.exception_arg_error(),
35
+ "invalid interval/lateness",
36
+ ));
37
+ }
38
+ Ok(this.0.borrow().closes(
39
+ Window {
40
+ start: start.0,
41
+ end: end.0,
42
+ },
43
+ lateness.0,
44
+ ))
45
+ }
46
+ }
47
+
48
+ pub fn define(ruby: &Ruby, module: RModule) -> Result<(), Error> {
49
+ let class = module.define_class("Watermark", ruby.class_object())?;
50
+ class.define_singleton_method("new", function!(Wrapper::new, 0))?;
51
+ class.define_method("advance", method!(Wrapper::advance, 1))?;
52
+ class.define_method("value", method!(Wrapper::value, 0))?;
53
+ class.define_method("closes?", method!(Wrapper::closes, 3))
54
+ }
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module WindowMachines
4
+ VERSION = '0.1.4'
5
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'window_machines/version'
4
+ require 'window_machines_native/window_machines_native'
@@ -0,0 +1,17 @@
1
+ module WindowMachines
2
+ VERSION: String
3
+ def self.tumbling: (Integer timestamp, Integer width) -> [Integer, Integer]
4
+ def self.sliding: (Integer timestamp, Integer width, Integer slide) -> Array[[Integer, Integer]]
5
+ class Watermark
6
+ def self.new: () -> instance
7
+ def advance: (Integer value) -> bool
8
+ def value: () -> Integer?
9
+ def closes?: (Integer start, Integer end, Integer lateness) -> bool
10
+ end
11
+ class Sessions
12
+ def self.new: (Integer gap) -> instance
13
+ def insert: (Integer timestamp) -> [Integer, Integer]
14
+ def advance_watermark: (Integer value) -> bool
15
+ def take_closed: () -> [Integer, Integer]?
16
+ end
17
+ end
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: window_machines
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.4
5
+ platform: ruby
6
+ authors:
7
+ - Abdelkader Boudih
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rb_sys
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.9'
19
+ - - ">="
20
+ - !ruby/object:Gem::Version
21
+ version: 0.9.130
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - "~>"
27
+ - !ruby/object:Gem::Version
28
+ version: '0.9'
29
+ - - ">="
30
+ - !ruby/object:Gem::Version
31
+ version: 0.9.130
32
+ description: Initial allocation-free window policy core and thin native Ruby binding.
33
+ Caller owns payloads, clocks, and execution.
34
+ email:
35
+ - oss@seuros.com
36
+ executables: []
37
+ extensions:
38
+ - ext/window_machines_native/extconf.rb
39
+ extra_rdoc_files: []
40
+ files:
41
+ - CHANGELOG.md
42
+ - Cargo.toml
43
+ - LICENSE.txt
44
+ - README.md
45
+ - ext/window_machines_native/core/CHANGELOG.md
46
+ - ext/window_machines_native/core/Cargo.toml
47
+ - ext/window_machines_native/core/README.md
48
+ - ext/window_machines_native/core/src/lib.rs
49
+ - ext/window_machines_native/core/src/sessions.rs
50
+ - ext/window_machines_native/extconf.rb
51
+ - ext/window_machines_native/ffi/Cargo.toml
52
+ - ext/window_machines_native/ffi/extconf.rb
53
+ - ext/window_machines_native/ffi/src/init.rs
54
+ - ext/window_machines_native/ffi/src/lib.rs
55
+ - ext/window_machines_native/ffi/src/sessions.rs
56
+ - ext/window_machines_native/ffi/src/watermark.rs
57
+ - lib/window_machines.rb
58
+ - lib/window_machines/version.rb
59
+ - sig/window_machines.rbs
60
+ homepage: https://github.com/seuros/window_machines
61
+ licenses:
62
+ - MIT
63
+ metadata:
64
+ source_code_uri: https://github.com/seuros/window_machines
65
+ rubygems_mfa_required: 'true'
66
+ cargo_crate_name: window_machines_native
67
+ cargo_manifest_path: ext/window_machines_native/ffi/Cargo.toml
68
+ rdoc_options: []
69
+ require_paths:
70
+ - lib
71
+ required_ruby_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: 4.0.0
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ requirements:
78
+ - - ">="
79
+ - !ruby/object:Gem::Version
80
+ version: '0'
81
+ requirements: []
82
+ rubygems_version: 4.0.20
83
+ specification_version: 4
84
+ summary: Native event-time window boundaries for Ruby
85
+ test_files: []