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 +7 -0
- data/CHANGELOG.md +29 -0
- data/Cargo.toml +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +198 -0
- data/ext/window_machines_native/core/CHANGELOG.md +22 -0
- data/ext/window_machines_native/core/Cargo.toml +13 -0
- data/ext/window_machines_native/core/README.md +22 -0
- data/ext/window_machines_native/core/src/lib.rs +85 -0
- data/ext/window_machines_native/core/src/sessions.rs +129 -0
- data/ext/window_machines_native/extconf.rb +3 -0
- data/ext/window_machines_native/ffi/Cargo.toml +20 -0
- data/ext/window_machines_native/ffi/extconf.rb +15 -0
- data/ext/window_machines_native/ffi/src/init.rs +10 -0
- data/ext/window_machines_native/ffi/src/lib.rs +46 -0
- data/ext/window_machines_native/ffi/src/sessions.rs +55 -0
- data/ext/window_machines_native/ffi/src/watermark.rs +54 -0
- data/lib/window_machines/version.rb +5 -0
- data/lib/window_machines.rb +4 -0
- data/sig/window_machines.rbs +17 -0
- metadata +85 -0
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,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,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: []
|