gridrunner 0.6.0__tar.gz

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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GRIDRUNNER authors
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 all
13
+ 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 THE
21
+ SOFTWARE.
@@ -0,0 +1,284 @@
1
+ Metadata-Version: 2.4
2
+ Name: gridrunner
3
+ Version: 0.6.0
4
+ Summary: Producer SDK for GRIDRUNNER — fire-and-forget job progress events
5
+ License: MIT
6
+ Classifier: License :: OSI Approved :: MIT License
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: Operating System :: OS Independent
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Dynamic: license-file
13
+
14
+ # GRIDRUNNER Python SDK
15
+
16
+ Standard-library-only producer client for reporting bounded jobs and
17
+ service status pings — super-light health monitoring for services and
18
+ recurring tasks — to GRIDRUNNER. See the [repository README](../README.md)
19
+ for the job/item/chunk/unit model, the ping mechanic, authentication,
20
+ delivery guarantees, and installation.
21
+
22
+ ## Configure
23
+
24
+ Initialize once near process startup with explicit credentials — the SDK
25
+ reads no environment variables and no config files:
26
+
27
+ ```python
28
+ import gridrunner as gr
29
+
30
+ client = gr.init(token=read_secret("gridrunner_produce_token"),
31
+ url="https://gridrunner.example.com")
32
+ ```
33
+
34
+ A token without `url=` raises `ValueError`. With neither, `init()` targets
35
+ the local unauthenticated dev core:
36
+
37
+ ```python
38
+ client = gr.init() # http://127.0.0.1:7077
39
+ ```
40
+
41
+ The token is also your identity: the name your producer was registered
42
+ with when the token was minted becomes the service name of every ping you
43
+ send.
44
+
45
+ All options:
46
+
47
+ ```python
48
+ client = gr.init(
49
+ token=token,
50
+ url="https://gridrunner.example.com",
51
+ flush_interval_s=0.2,
52
+ queue_size=20_000,
53
+ retry_interval_s=5.0,
54
+ )
55
+ ```
56
+
57
+ ## Preferred usage
58
+
59
+ Declare chunks up front and use context managers. Successful exits emit
60
+ completion; exceptions emit failure and are re-raised.
61
+
62
+ ```python
63
+ import gridrunner as gr
64
+
65
+ gr.init(token=read_secret("gridrunner_produce_token"),
66
+ url="https://gridrunner.example.com")
67
+
68
+ chunks = [
69
+ {
70
+ "chunk_id": f"orders:{month}",
71
+ "item": "orders",
72
+ "label": month,
73
+ "units": estimated_rows,
74
+ }
75
+ for month, estimated_rows in monthly_estimates.items()
76
+ ]
77
+
78
+ with gr.job(
79
+ "etl.orders.monthly.v1",
80
+ chunks=chunks,
81
+ label="Export orders",
82
+ meta={"service": "billing-export"},
83
+ heartbeat_s=30,
84
+ ) as job:
85
+ for spec in chunks:
86
+ with job.chunk(spec["chunk_id"], worker=worker_name):
87
+ export_month(spec["label"])
88
+ ```
89
+
90
+ ## Manual lifecycle
91
+
92
+ Use manual methods when a context manager does not match the host framework:
93
+
94
+ ```python
95
+ job = gr.job(
96
+ "model.forecast.v3",
97
+ chunks=[
98
+ {"chunk_id": "load", "item": "prepare", "units": 1},
99
+ {"chunk_id": "fit", "item": "model", "units": 10},
100
+ {"chunk_id": "write", "item": "output", "units": 1},
101
+ ],
102
+ expected_silence_s=300,
103
+ ).register()
104
+
105
+ try:
106
+ with job.chunk("fit"):
107
+ fit_model()
108
+ job.complete()
109
+ except Exception as exc:
110
+ job.fail(f"{type(exc).__name__}: {exc}")
111
+ raise
112
+ ```
113
+
114
+ For one genuinely indivisible chunk, report a measured fraction:
115
+
116
+ ```python
117
+ with job.chunk("fit") as chunk:
118
+ for completed, total in train():
119
+ chunk.progress(completed / total)
120
+ ```
121
+
122
+ ## Service pings
123
+
124
+ For services and independent regular jobs — cron cycles, daemons,
125
+ schedulers — use the ping mechanic instead of jobs: one call per cycle,
126
+ and GRIDRUNNER learns the cadence and alarms on silence or errors by
127
+ itself.
128
+
129
+ The ping only names the recurring process (`ping_type` — an
130
+ understandable name like `daily-export` or `queue-sweep`). The service
131
+ name is resolved server-side from the produce token — the name the
132
+ producer was registered with — so producers can never contaminate each
133
+ other's series.
134
+
135
+ ```python
136
+ def daily_cycle():
137
+ try:
138
+ run_export()
139
+ gr.ping("daily-export")
140
+ except Exception as exc:
141
+ gr.ping("daily-export", status="error",
142
+ description=f"{type(exc).__name__}: {exc}")
143
+ raise
144
+ ```
145
+
146
+ If the cadence is too sparse to learn quickly (weekly, monthly), declare
147
+ when the next ping is due — GRIDRUNNER alarms on that deadline instead of
148
+ waiting to learn the pattern:
149
+
150
+ ```python
151
+ gr.ping("monthly-report", expected_next_ts=next_run_at) # epoch ms or datetime
152
+ ```
153
+
154
+ For cron scripts that shouldn't carry a client lifecycle at all, use the
155
+ one-shot call — token and ping in one line, no `init`, no queue:
156
+
157
+ ```python
158
+ gr.ping_once("daily-export", token=tok, url=core_url)
159
+ ```
160
+
161
+ See [Service pings](../README.md#service-pings) in the repository README
162
+ for the full contract.
163
+
164
+ ## Chunk plan formats
165
+
166
+ Full dictionaries preserve item grouping and weights:
167
+
168
+ ```python
169
+ chunks = [
170
+ {"chunk_id": "users:0", "item": "users", "label": "0–49k", "units": 50_000},
171
+ {"chunk_id": "users:1", "item": "users", "label": "50k–99k", "units": 50_000},
172
+ ]
173
+ ```
174
+
175
+ Convenience forms are accepted:
176
+
177
+ ```python
178
+ chunks = {"users:0": 50_000, "users:1": 50_000}
179
+ chunks = ["load", "fit", "write"] # each gets one unit
180
+ ```
181
+
182
+ ## API
183
+
184
+ ### `init(token=None, url=None, **client_options)`
185
+
186
+ Configures the module-level client used by `emit()`, `job()`, and
187
+ `ping()`. A token requires an explicit `url` (`ValueError` otherwise);
188
+ with neither it targets a local core at `http://127.0.0.1:7077`. The
189
+ target never changes behind the caller's back. `connect(url=None,
190
+ token=None, ...)` remains as a deprecated alias.
191
+
192
+ ### `job(job_type_id, chunks=None, **options) -> Job`
193
+
194
+ Options:
195
+
196
+ | Option | Meaning |
197
+ |---|---|
198
+ | `label` | Human-readable run label |
199
+ | `total_units` | Override sum of chunk units |
200
+ | `meta` | JSON metadata attached to registration |
201
+ | `job_id` | Stable caller-provided run ID; otherwise generated ULID |
202
+ | `heartbeat_s` | Automatic heartbeat interval |
203
+ | `expected_silence_s` | Expected quiet-work bound |
204
+
205
+ ### `Job`
206
+
207
+ - `register()` — emits `job.registered` and starts heartbeat.
208
+ - `chunk(id, units=None, worker=None, item=None)` — returns a chunk context.
209
+ - `complete()` — stops heartbeat and emits `job.completed`.
210
+ - `fail(error="")` — stops heartbeat and emits `job.failed`.
211
+
212
+ ### `Chunk`
213
+
214
+ - Context entry emits `chunk.started`.
215
+ - `progress(fraction)` emits a clamped measured fraction in `[0, 1]`.
216
+ - Normal context exit emits `chunk.completed` with duration.
217
+ - Exceptional context exit emits `chunk.failed` and re-raises.
218
+
219
+ ### `ping(ping_type="default", status="ok", description=None, expected_next_ts=None)`
220
+
221
+ Reports a recurring status ping — a mechanic separate from jobs (see the
222
+ [repository README](../README.md#service-pings)). The service name is
223
+ resolved server-side from the produce token; `ping_type` names the
224
+ recurring process:
225
+
226
+ ```python
227
+ gr.ping("daily-export")
228
+ gr.ping("daily-export", status="error", description="table locked")
229
+ gr.ping("monthly-report", expected_next_ts=next_run_at)
230
+ ```
231
+
232
+ `expected_next_ts` (epoch ms or a `datetime`) optionally declares when the
233
+ next ping is due, for cadences too sparse to learn quickly.
234
+
235
+ ### `ping_once(ping_type="default", *, token=None, status="ok", description=None, expected_next_ts=None, url=None, wait=False, timeout_s=5.0)`
236
+
237
+ One-shot stateless ping for cron scripts and one-liners: token and ping in
238
+ the same call, one direct HTTP POST — no `init`, no persistent client, no
239
+ queue, no backlog (outage-safe replay is what you give up).
240
+
241
+ Fire-and-forget by default: returns `None` immediately and delivers on a
242
+ short-lived non-daemon thread, so a script that exits right after pinging
243
+ doesn't lose the ping (the process lingers at most `timeout_s`).
244
+ `wait=True` performs the POST inline and returns `True`/`False`. Never
245
+ raises either way. `url` resolves like `init`: required alongside a token
246
+ (a token without `url` is logged and dropped), the local dev core without
247
+ one.
248
+
249
+ ```python
250
+ gr.ping_once("daily-export", token=tok, url=core_url) # returns immediately
251
+ ok = gr.ping_once("daily-export", token=tok, url=core_url, wait=True) # opt-in: block → bool
252
+ gr.ping_once("daily-export", token=tok, url=core_url, status="error",
253
+ description="table locked")
254
+ ```
255
+
256
+ ### `emit(event)`
257
+
258
+ Queues a raw event on the configured module client. Prefer the structured
259
+ job/chunk API unless integrating an unsupported lifecycle.
260
+
261
+ ### `Client`
262
+
263
+ An independent client for applications that cannot use module-level state:
264
+
265
+ ```python
266
+ client = gr.Client(url, token=token)
267
+ client.emit({"type": "job.heartbeat", "job_id": job_id})
268
+ client.close()
269
+ ```
270
+
271
+ Call `close()` during graceful shutdown to flush the in-memory queue.
272
+
273
+ ## Failure behavior
274
+
275
+ - Connection and HTTP errors never propagate into host work.
276
+ - Undelivered events are held in a bounded, liveness-scoped in-memory
277
+ backlog: live jobs keep compacted events (latest progress per chunk, no
278
+ heartbeats; ok pings compact to the newest per series, error pings are
279
+ all kept) and replay on reconnect; jobs that start and finish entirely
280
+ while the core is unreachable are discarded, never sent late.
281
+ - Events are assigned idempotency IDs before queueing.
282
+ - Non-finite floats are converted to JSON `null`.
283
+ - A full queue drops new events and logs at increasing thresholds; held
284
+ backlogs log a rate-limited warning (distinct message on 401).
@@ -0,0 +1,271 @@
1
+ # GRIDRUNNER Python SDK
2
+
3
+ Standard-library-only producer client for reporting bounded jobs and
4
+ service status pings — super-light health monitoring for services and
5
+ recurring tasks — to GRIDRUNNER. See the [repository README](../README.md)
6
+ for the job/item/chunk/unit model, the ping mechanic, authentication,
7
+ delivery guarantees, and installation.
8
+
9
+ ## Configure
10
+
11
+ Initialize once near process startup with explicit credentials — the SDK
12
+ reads no environment variables and no config files:
13
+
14
+ ```python
15
+ import gridrunner as gr
16
+
17
+ client = gr.init(token=read_secret("gridrunner_produce_token"),
18
+ url="https://gridrunner.example.com")
19
+ ```
20
+
21
+ A token without `url=` raises `ValueError`. With neither, `init()` targets
22
+ the local unauthenticated dev core:
23
+
24
+ ```python
25
+ client = gr.init() # http://127.0.0.1:7077
26
+ ```
27
+
28
+ The token is also your identity: the name your producer was registered
29
+ with when the token was minted becomes the service name of every ping you
30
+ send.
31
+
32
+ All options:
33
+
34
+ ```python
35
+ client = gr.init(
36
+ token=token,
37
+ url="https://gridrunner.example.com",
38
+ flush_interval_s=0.2,
39
+ queue_size=20_000,
40
+ retry_interval_s=5.0,
41
+ )
42
+ ```
43
+
44
+ ## Preferred usage
45
+
46
+ Declare chunks up front and use context managers. Successful exits emit
47
+ completion; exceptions emit failure and are re-raised.
48
+
49
+ ```python
50
+ import gridrunner as gr
51
+
52
+ gr.init(token=read_secret("gridrunner_produce_token"),
53
+ url="https://gridrunner.example.com")
54
+
55
+ chunks = [
56
+ {
57
+ "chunk_id": f"orders:{month}",
58
+ "item": "orders",
59
+ "label": month,
60
+ "units": estimated_rows,
61
+ }
62
+ for month, estimated_rows in monthly_estimates.items()
63
+ ]
64
+
65
+ with gr.job(
66
+ "etl.orders.monthly.v1",
67
+ chunks=chunks,
68
+ label="Export orders",
69
+ meta={"service": "billing-export"},
70
+ heartbeat_s=30,
71
+ ) as job:
72
+ for spec in chunks:
73
+ with job.chunk(spec["chunk_id"], worker=worker_name):
74
+ export_month(spec["label"])
75
+ ```
76
+
77
+ ## Manual lifecycle
78
+
79
+ Use manual methods when a context manager does not match the host framework:
80
+
81
+ ```python
82
+ job = gr.job(
83
+ "model.forecast.v3",
84
+ chunks=[
85
+ {"chunk_id": "load", "item": "prepare", "units": 1},
86
+ {"chunk_id": "fit", "item": "model", "units": 10},
87
+ {"chunk_id": "write", "item": "output", "units": 1},
88
+ ],
89
+ expected_silence_s=300,
90
+ ).register()
91
+
92
+ try:
93
+ with job.chunk("fit"):
94
+ fit_model()
95
+ job.complete()
96
+ except Exception as exc:
97
+ job.fail(f"{type(exc).__name__}: {exc}")
98
+ raise
99
+ ```
100
+
101
+ For one genuinely indivisible chunk, report a measured fraction:
102
+
103
+ ```python
104
+ with job.chunk("fit") as chunk:
105
+ for completed, total in train():
106
+ chunk.progress(completed / total)
107
+ ```
108
+
109
+ ## Service pings
110
+
111
+ For services and independent regular jobs — cron cycles, daemons,
112
+ schedulers — use the ping mechanic instead of jobs: one call per cycle,
113
+ and GRIDRUNNER learns the cadence and alarms on silence or errors by
114
+ itself.
115
+
116
+ The ping only names the recurring process (`ping_type` — an
117
+ understandable name like `daily-export` or `queue-sweep`). The service
118
+ name is resolved server-side from the produce token — the name the
119
+ producer was registered with — so producers can never contaminate each
120
+ other's series.
121
+
122
+ ```python
123
+ def daily_cycle():
124
+ try:
125
+ run_export()
126
+ gr.ping("daily-export")
127
+ except Exception as exc:
128
+ gr.ping("daily-export", status="error",
129
+ description=f"{type(exc).__name__}: {exc}")
130
+ raise
131
+ ```
132
+
133
+ If the cadence is too sparse to learn quickly (weekly, monthly), declare
134
+ when the next ping is due — GRIDRUNNER alarms on that deadline instead of
135
+ waiting to learn the pattern:
136
+
137
+ ```python
138
+ gr.ping("monthly-report", expected_next_ts=next_run_at) # epoch ms or datetime
139
+ ```
140
+
141
+ For cron scripts that shouldn't carry a client lifecycle at all, use the
142
+ one-shot call — token and ping in one line, no `init`, no queue:
143
+
144
+ ```python
145
+ gr.ping_once("daily-export", token=tok, url=core_url)
146
+ ```
147
+
148
+ See [Service pings](../README.md#service-pings) in the repository README
149
+ for the full contract.
150
+
151
+ ## Chunk plan formats
152
+
153
+ Full dictionaries preserve item grouping and weights:
154
+
155
+ ```python
156
+ chunks = [
157
+ {"chunk_id": "users:0", "item": "users", "label": "0–49k", "units": 50_000},
158
+ {"chunk_id": "users:1", "item": "users", "label": "50k–99k", "units": 50_000},
159
+ ]
160
+ ```
161
+
162
+ Convenience forms are accepted:
163
+
164
+ ```python
165
+ chunks = {"users:0": 50_000, "users:1": 50_000}
166
+ chunks = ["load", "fit", "write"] # each gets one unit
167
+ ```
168
+
169
+ ## API
170
+
171
+ ### `init(token=None, url=None, **client_options)`
172
+
173
+ Configures the module-level client used by `emit()`, `job()`, and
174
+ `ping()`. A token requires an explicit `url` (`ValueError` otherwise);
175
+ with neither it targets a local core at `http://127.0.0.1:7077`. The
176
+ target never changes behind the caller's back. `connect(url=None,
177
+ token=None, ...)` remains as a deprecated alias.
178
+
179
+ ### `job(job_type_id, chunks=None, **options) -> Job`
180
+
181
+ Options:
182
+
183
+ | Option | Meaning |
184
+ |---|---|
185
+ | `label` | Human-readable run label |
186
+ | `total_units` | Override sum of chunk units |
187
+ | `meta` | JSON metadata attached to registration |
188
+ | `job_id` | Stable caller-provided run ID; otherwise generated ULID |
189
+ | `heartbeat_s` | Automatic heartbeat interval |
190
+ | `expected_silence_s` | Expected quiet-work bound |
191
+
192
+ ### `Job`
193
+
194
+ - `register()` — emits `job.registered` and starts heartbeat.
195
+ - `chunk(id, units=None, worker=None, item=None)` — returns a chunk context.
196
+ - `complete()` — stops heartbeat and emits `job.completed`.
197
+ - `fail(error="")` — stops heartbeat and emits `job.failed`.
198
+
199
+ ### `Chunk`
200
+
201
+ - Context entry emits `chunk.started`.
202
+ - `progress(fraction)` emits a clamped measured fraction in `[0, 1]`.
203
+ - Normal context exit emits `chunk.completed` with duration.
204
+ - Exceptional context exit emits `chunk.failed` and re-raises.
205
+
206
+ ### `ping(ping_type="default", status="ok", description=None, expected_next_ts=None)`
207
+
208
+ Reports a recurring status ping — a mechanic separate from jobs (see the
209
+ [repository README](../README.md#service-pings)). The service name is
210
+ resolved server-side from the produce token; `ping_type` names the
211
+ recurring process:
212
+
213
+ ```python
214
+ gr.ping("daily-export")
215
+ gr.ping("daily-export", status="error", description="table locked")
216
+ gr.ping("monthly-report", expected_next_ts=next_run_at)
217
+ ```
218
+
219
+ `expected_next_ts` (epoch ms or a `datetime`) optionally declares when the
220
+ next ping is due, for cadences too sparse to learn quickly.
221
+
222
+ ### `ping_once(ping_type="default", *, token=None, status="ok", description=None, expected_next_ts=None, url=None, wait=False, timeout_s=5.0)`
223
+
224
+ One-shot stateless ping for cron scripts and one-liners: token and ping in
225
+ the same call, one direct HTTP POST — no `init`, no persistent client, no
226
+ queue, no backlog (outage-safe replay is what you give up).
227
+
228
+ Fire-and-forget by default: returns `None` immediately and delivers on a
229
+ short-lived non-daemon thread, so a script that exits right after pinging
230
+ doesn't lose the ping (the process lingers at most `timeout_s`).
231
+ `wait=True` performs the POST inline and returns `True`/`False`. Never
232
+ raises either way. `url` resolves like `init`: required alongside a token
233
+ (a token without `url` is logged and dropped), the local dev core without
234
+ one.
235
+
236
+ ```python
237
+ gr.ping_once("daily-export", token=tok, url=core_url) # returns immediately
238
+ ok = gr.ping_once("daily-export", token=tok, url=core_url, wait=True) # opt-in: block → bool
239
+ gr.ping_once("daily-export", token=tok, url=core_url, status="error",
240
+ description="table locked")
241
+ ```
242
+
243
+ ### `emit(event)`
244
+
245
+ Queues a raw event on the configured module client. Prefer the structured
246
+ job/chunk API unless integrating an unsupported lifecycle.
247
+
248
+ ### `Client`
249
+
250
+ An independent client for applications that cannot use module-level state:
251
+
252
+ ```python
253
+ client = gr.Client(url, token=token)
254
+ client.emit({"type": "job.heartbeat", "job_id": job_id})
255
+ client.close()
256
+ ```
257
+
258
+ Call `close()` during graceful shutdown to flush the in-memory queue.
259
+
260
+ ## Failure behavior
261
+
262
+ - Connection and HTTP errors never propagate into host work.
263
+ - Undelivered events are held in a bounded, liveness-scoped in-memory
264
+ backlog: live jobs keep compacted events (latest progress per chunk, no
265
+ heartbeats; ok pings compact to the newest per series, error pings are
266
+ all kept) and replay on reconnect; jobs that start and finish entirely
267
+ while the core is unreachable are discarded, never sent late.
268
+ - Events are assigned idempotency IDs before queueing.
269
+ - Non-finite floats are converted to JSON `null`.
270
+ - A full queue drops new events and logs at increasing thresholds; held
271
+ backlogs log a rate-limited warning (distinct message on 401).
@@ -0,0 +1,25 @@
1
+ """GRIDRUNNER producer SDK — fire-and-forget INGEST job events and the
2
+ SIMULATE run stream."""
3
+
4
+ from .ids import ulid
5
+ from .sdk import (
6
+ Chunk, Client, Job, Run, connect, emit, init, job, manifest, ping,
7
+ ping_once, run,
8
+ )
9
+
10
+ __version__ = "0.6.0"
11
+ __all__ = [
12
+ "Chunk",
13
+ "Client",
14
+ "Job",
15
+ "Run",
16
+ "connect",
17
+ "emit",
18
+ "init",
19
+ "job",
20
+ "manifest",
21
+ "ping",
22
+ "ping_once",
23
+ "run",
24
+ "ulid",
25
+ ]
@@ -0,0 +1,29 @@
1
+ """ULID generation (stdlib-only)."""
2
+
3
+ import os
4
+ import threading
5
+ import time
6
+
7
+ _ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
8
+ _lock = threading.Lock()
9
+ _last: list = [0, 0] # [ts_ms, randomness as int]
10
+
11
+
12
+ def _encode(value: int, length: int) -> str:
13
+ out = []
14
+ for _ in range(length):
15
+ out.append(_ENCODING[value & 0x1F])
16
+ value >>= 5
17
+ return "".join(reversed(out))
18
+
19
+
20
+ def ulid() -> str:
21
+ with _lock:
22
+ ts = int(time.time() * 1000)
23
+ if ts == _last[0]:
24
+ _last[1] += 1
25
+ else:
26
+ _last[0] = ts
27
+ _last[1] = int.from_bytes(os.urandom(10), "big")
28
+ rand = _last[1] & ((1 << 80) - 1)
29
+ return _encode(ts, 10) + _encode(rand, 16)