discover
blobmap.discover.scan ¶
LIST-driven discovery: find zarr stores, and which of them lack a manifest.
No database. A delimited walk plus an existence check, which is what keeps blobmap testable end to end against a memory backend.
Candidate
dataclass
¶
Candidate(scope: str, fmt: str, has_manifest: bool)
A zarr store found by a scan.
Attributes:
| Name | Type | Description |
|---|---|---|
scope |
str
|
The store prefix, relative to the data store root. |
fmt |
str
|
|
has_manifest |
bool
|
Whether this scope has already been partitioned. |
scan ¶
scan(
data: Store,
root: str,
manifests: ManifestStore,
*,
max_depth: int = 6,
exclude: Sequence[str] = DEFAULT_EXCLUDE
) -> Iterator[Candidate]
Walk a prefix and yield the zarr stores under it.
Descent stops at a store boundary. A datatree may put an entire bucket in one store, so walking in to look for more would mean listing hundreds of thousands of chunk keys to no purpose.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
data
|
Store
|
A storage handle for the data. |
required |
root
|
str
|
Prefix to scan under. Empty scans everything. |
required |
manifests
|
ManifestStore
|
Used only to find out which scopes are already known. |
required |
max_depth
|
int
|
How far to descend before giving up on a branch. A store nested deeper than this is not found, so raise it rather than wonder why something is missing. |
6
|
exclude
|
Sequence[str]
|
Path segments not to descend into, defaulting to
|
DEFAULT_EXCLUDE
|
Yields:
| Type | Description |
|---|---|
Candidate
|
One |
Example
for candidate in scan(data, "cordex", manifests):
if not candidate.has_manifest:
partition_store(data, manifests, candidate.scope)
Source code in src/blobmap/discover/scan.py
35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 | |
blobmap.discover.events ¶
Poll-driven discovery from MinIO's Postgres notification target.
Polling a table is a fine event source here. Latency does not matter: the one thing that needed it -- restore -- is a synchronous path in blobtier, not an event. A cursor also gives replay for free, and lets blobtier hold its own position in the same table with no coordination between the two consumers.
MinIO must be configured with format=access (an append-only log, not one
upserted row per key) and with queue_dir set, so a database blip spools to
local disk instead of silently dropping events.
Two filters do the work:
- only ObjectCreated on a metadata object is interesting here. A new variable, group or store always writes one. Chunk writes are blobtier's business, and filtering them here is what stops a conversion run's 300k events from becoming 300k queue entries.
- debounce. Never partition a store that is still being written: sizes are half complete, the cut lands in the wrong place, and the trailing bucket is not sealed.
Cursor ¶
Bases: Protocol
The DB-API subset the poller needs.
Supplied by the caller, so blobmap carries no database driver dependency of its own and can be tested against sqlite.
PollConfig
dataclass
¶
PollConfig(
table: str = "minio_events",
consumer: str = "blobmap",
batch: int = 5000,
quiet_seconds: float = 1800,
poll_seconds: float = 30,
ignore_principals: tuple[str, ...] = (),
placeholder: str = "%s",
)
How to read the notification table and when to act on it.
Attributes:
| Name | Type | Description |
|---|---|---|
table |
str
|
Table MinIO writes notifications to. |
consumer |
str
|
Name this poller stores its cursor under. Give blobtier a different one and the two consume the same table independently. |
batch |
int
|
Rows per query. |
quiet_seconds |
float
|
How long a store must be silent before it is safe to partition. Set it above your normal gap between writes, or a conversion run gets partitioned halfway through. |
poll_seconds |
float
|
Sleep between polls in
|
ignore_principals |
tuple[str, ...]
|
Service accounts whose activity does not count. Without this, the partitioner's own metadata reads re-enqueue the store it just finished. |
placeholder |
str
|
Parameter style. |
EventPoller ¶
EventPoller(
cursor: Cursor,
state_path: str = ":memory:",
config: PollConfig | None = None,
root_of: Callable[[str], str | None] | None = None,
)
Turns a stream of object keys into debounced store scopes.
State is SQLite: the poll cursor and the pending set. Local, single writer, survives a restart. That is what SQLite is actually good at, unlike holding manifests, where one file would need a lock across writers and rewrite everything to record one store.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
cursor
|
Cursor
|
A DB-API cursor on the notification database. |
required |
state_path
|
str
|
SQLite file for the cursor and pending set. The default keeps it in memory, which loses the position on restart. |
':memory:'
|
config
|
PollConfig | None
|
Polling and debounce settings. |
None
|
root_of
|
Callable[[str], str | None] | None
|
Maps the node a metadata object sits in to its store root,
normally |
None
|
Example
import psycopg
from blobmap import EventPoller, PollConfig, find_store_root
conn = psycopg.connect("postgresql://...")
poller = EventPoller(
conn.cursor(),
state_path="/var/lib/blobmap/state.sqlite",
config=PollConfig(ignore_principals=("blobmap-svc",)),
root_of=lambda prefix: find_store_root(data, prefix),
)
poller.run(lambda scope: partition_store(data, manifests, scope))
Source code in src/blobmap/discover/events.py
115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 | |
position
property
¶
position: int
Last event id consumed.
Returns:
| Type | Description |
|---|---|
int
|
The cursor position, 0 before the first poll. |
close ¶
close() -> None
Close the SQLite state connection.
Source code in src/blobmap/discover/events.py
142 143 144 | |
poll_once ¶
poll_once(now: float | None = None) -> int
Fetch one batch and mark the affected scopes pending.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
now
|
float | None
|
Timestamp to record. Defaults to the wall clock; pass a value in tests. |
None
|
Returns:
| Type | Description |
|---|---|
int
|
Rows read. Equal to |
Source code in src/blobmap/discover/events.py
148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 | |
drain ¶
drain(now: float | None = None) -> int
Poll until the table is caught up.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
now
|
float | None
|
Timestamp to record against touched scopes. |
None
|
Returns:
| Type | Description |
|---|---|
int
|
Total rows read across all batches. |
Source code in src/blobmap/discover/events.py
185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 | |
due ¶
due(now: float | None = None) -> list[str]
Scopes that have been quiet long enough to partition safely.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
now
|
float | None
|
Reference time. |
None
|
Returns:
| Type | Description |
|---|---|
list[str]
|
Scope prefixes, sorted. A store still being written is held back: |
list[str]
|
its sizes are half complete, so the cut would land in the wrong |
list[str]
|
place. |
Source code in src/blobmap/discover/events.py
201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 | |
pending ¶
pending() -> dict[str, float]
Everything waiting for quiet.
Returns:
| Type | Description |
|---|---|
dict[str, float]
|
Mapping of scope to the time it was last seen being written. |
Source code in src/blobmap/discover/events.py
219 220 221 222 223 224 225 226 227 228 | |
touch ¶
touch(scope: str, now: float | None = None) -> None
Mark a scope as recently written, resetting its debounce.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scope
|
str
|
Store prefix. |
required |
now
|
float | None
|
Time to record. |
None
|
Source code in src/blobmap/discover/events.py
230 231 232 233 234 235 236 237 238 239 240 241 | |
clear ¶
clear(scope: str) -> None
Drop a scope from the pending set, once handled.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scope
|
str
|
Store prefix. |
required |
Source code in src/blobmap/discover/events.py
243 244 245 246 247 248 249 | |
step ¶
step(
handler: Callable[[str], Any], now: float | None = None
) -> list[str]
One poll and dispatch cycle.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
handler
|
Callable[[str], Any]
|
Called with each due scope, normally
|
required |
now
|
float | None
|
Reference time. |
None
|
Returns:
| Type | Description |
|---|---|
list[str]
|
Scopes handled successfully. A scope whose handler raised is |
list[str]
|
logged and left pending, so one unreachable store cannot stall |
list[str]
|
the rest. |
Source code in src/blobmap/discover/events.py
251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 | |
run ¶
run(handler: Callable[[str], Any]) -> None
Poll and dispatch forever.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
handler
|
Callable[[str], Any]
|
Called with each due scope. |
required |
Source code in src/blobmap/discover/events.py
279 280 281 282 283 284 285 286 287 | |
store_of ¶
store_of(key: str) -> str | None
Get the prefix to re-examine after a write, or None to ignore it.
Deliberately returns the metadata object's parent rather than the store
root: resolving the root needs a LIST, which belongs in root_of. What
matters here is that chunk writes produce nothing at all, which is what
stops a conversion run's 300,000 events from becoming 300,000 queue
entries.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
key
|
str
|
Object key from a notification row. |
required |
Returns:
| Type | Description |
|---|---|
str | None
|
The parent prefix for a metadata write, else |
Example
store_of("a/b.zarr/tas/zarr.json") 'a/b.zarr/tas' store_of("a/b.zarr/tas/c/0/0") is None True
Source code in src/blobmap/discover/events.py
316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 | |