Data waiting for the upload, kept in RAM, capped in bytes.
While the network is down the manager keeps what it gathers here and the sender
takes it out again, oldest first. The frames are the ones the upload reads
(see upload.prepare_frames: one row per second, only the columns that go on the
wire), so they are small, and they stay frames so that the sender can merge them
into as large a request as the backend is used to.
Snapshots are merged into chunks of up to CHUNK_SECONDS of data. A pandas frame
has a fixed overhead of about 5 KB, which is more than the data of a 30 s
snapshot of one device: kept one by one, a day would cost about 80 % on top of
the data, in chunks of 600 s less than 10 %.
Chunk
dataclass
The frames of up to CHUNK_SECONDS of data, indexed by unix seconds.
Source code in src/naneos/cloud/backlog.py
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58 | @dataclass
class Chunk:
"""The frames of up to CHUNK_SECONDS of data, indexed by unix seconds."""
frames: dict[int, pd.DataFrame]
first: int # unix seconds of the first row of any device
last: int
snapshots: int = 1 # how many snapshots were merged into it
sealed: bool = False # taken once: nothing is merged into it any more
nbytes: int = field(init=False, default=0)
def __post_init__(self) -> None:
self.nbytes = _size_of(self.frames)
@property
def seconds(self) -> int:
return self.last - self.first + 1
@property
def rows(self) -> int:
"""The rows of all devices: what the backend has to write for this chunk."""
return sum(len(df) for df in self.frames.values())
|
rows
property
The rows of all devices: what the backend has to write for this chunk.
Eviction
Bases: NamedTuple
What a full buffer threw away.
Source code in src/naneos/cloud/backlog.py
| class Eviction(NamedTuple):
"""What a full buffer threw away."""
snapshots: int
seconds: int # of data, counted per chunk (devices in parallel are not added up)
|
UploadBacklog
A FIFO of chunks, thread-safe: the manager adds, the sender takes.
Parameters:
| Name |
Type |
Description |
Default |
max_bytes
|
int
|
the cap. It is soft by one chunk: the newest chunk is never
evicted, so a cap below the size of one chunk still keeps the latest data.
|
required
|
chunk_seconds
|
int
|
the most data merged into one chunk.
|
CHUNK_SECONDS
|
Source code in src/naneos/cloud/backlog.py
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
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 | class UploadBacklog:
"""A FIFO of chunks, thread-safe: the manager adds, the sender takes.
Args:
max_bytes: the cap. It is soft by one chunk: the newest chunk is never
evicted, so a cap below the size of one chunk still keeps the latest data.
chunk_seconds: the most data merged into one chunk.
"""
def __init__(self, max_bytes: int, chunk_seconds: int = CHUNK_SECONDS) -> None:
self._max_bytes = max_bytes
self._chunk_seconds = chunk_seconds
self._chunks: deque[Chunk] = deque()
self._bytes = 0
self._lock = threading.Lock()
def add(self, frames: dict[int, pd.DataFrame]) -> Eviction | None:
"""Keep the frames of one snapshot. Returns what had to go to stay under the cap."""
frames = {sn: df for sn, df in frames.items() if not df.empty}
if not frames:
return None
first = min(int(df.index.min()) for df in frames.values())
last = max(int(df.index.max()) for df in frames.values())
with self._lock:
tail = self._chunks[-1] if self._chunks else None
if tail is not None and not tail.sealed and last - tail.first < self._chunk_seconds:
self._bytes -= tail.nbytes
self._merge(tail, frames, last)
self._bytes += tail.nbytes
else:
chunk = Chunk(frames, first, last)
self._chunks.append(chunk)
self._bytes += chunk.nbytes
return self._evict()
def take(self, max_seconds: int | None = None, max_rows: int | None = None) -> Chunk | None:
"""Remove and return the oldest chunk, or the first part of it.
The part is at most `max_seconds` long and holds about `max_rows` rows (all
devices together; the rows are taken to be spread evenly over the time).
What the caller could not send goes back with restore().
"""
with self._lock:
if not self._chunks:
return None
chunk = self._chunks.popleft()
self._bytes -= chunk.nbytes
chunk.sealed = True
limit = chunk.seconds if max_seconds is None else max_seconds
if max_rows is not None and chunk.rows > max_rows:
limit = min(limit, max(1, int(chunk.seconds * max_rows / chunk.rows)))
if chunk.seconds > limit:
chunk, rest = _split(chunk, chunk.first + limit)
self._chunks.appendleft(rest)
self._bytes += rest.nbytes
return chunk
def restore(self, chunk: Chunk) -> Eviction | None:
"""Put a chunk that could not be sent back at the head."""
with self._lock:
chunk.sealed = True
self._chunks.appendleft(chunk)
self._bytes += chunk.nbytes
return self._evict()
@property
def size_bytes(self) -> int:
return self._bytes
@property
def snapshots(self) -> int:
"""The number of snapshots waiting."""
with self._lock:
return sum(chunk.snapshots for chunk in self._chunks)
@property
def seconds(self) -> int:
"""Seconds of data waiting, counted per chunk."""
with self._lock:
return sum(chunk.seconds for chunk in self._chunks)
def __len__(self) -> int:
return len(self._chunks)
@staticmethod
def _merge(tail: Chunk, frames: dict[int, pd.DataFrame], last: int) -> None:
for sn, df in frames.items():
known = tail.frames.get(sn)
if known is None:
tail.frames[sn] = df
else:
merged = pd.concat([known, df])
# A 10 or 100 Hz device can put rows of two snapshots into the same second.
tail.frames[sn] = aggregate_duplicate_index(merged)
tail.last = max(tail.last, last)
tail.snapshots += 1
tail.nbytes = _size_of(tail.frames)
def _evict(self) -> Eviction | None:
"""Drop the oldest chunks while over the cap; the caller holds the lock."""
snapshots = seconds = 0
while self._bytes > self._max_bytes and len(self._chunks) > 1:
dropped = self._chunks.popleft()
self._bytes -= dropped.nbytes
snapshots += dropped.snapshots
seconds += dropped.seconds
return Eviction(snapshots, seconds) if snapshots else None
|
seconds
property
Seconds of data waiting, counted per chunk.
snapshots
property
The number of snapshots waiting.
add(frames)
Keep the frames of one snapshot. Returns what had to go to stay under the cap.
Source code in src/naneos/cloud/backlog.py
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101 | def add(self, frames: dict[int, pd.DataFrame]) -> Eviction | None:
"""Keep the frames of one snapshot. Returns what had to go to stay under the cap."""
frames = {sn: df for sn, df in frames.items() if not df.empty}
if not frames:
return None
first = min(int(df.index.min()) for df in frames.values())
last = max(int(df.index.max()) for df in frames.values())
with self._lock:
tail = self._chunks[-1] if self._chunks else None
if tail is not None and not tail.sealed and last - tail.first < self._chunk_seconds:
self._bytes -= tail.nbytes
self._merge(tail, frames, last)
self._bytes += tail.nbytes
else:
chunk = Chunk(frames, first, last)
self._chunks.append(chunk)
self._bytes += chunk.nbytes
return self._evict()
|
restore(chunk)
Put a chunk that could not be sent back at the head.
Source code in src/naneos/cloud/backlog.py
126
127
128
129
130
131
132 | def restore(self, chunk: Chunk) -> Eviction | None:
"""Put a chunk that could not be sent back at the head."""
with self._lock:
chunk.sealed = True
self._chunks.appendleft(chunk)
self._bytes += chunk.nbytes
return self._evict()
|
take(max_seconds=None, max_rows=None)
Remove and return the oldest chunk, or the first part of it.
The part is at most max_seconds long and holds about max_rows rows (all
devices together; the rows are taken to be spread evenly over the time).
What the caller could not send goes back with restore().
Source code in src/naneos/cloud/backlog.py
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124 | def take(self, max_seconds: int | None = None, max_rows: int | None = None) -> Chunk | None:
"""Remove and return the oldest chunk, or the first part of it.
The part is at most `max_seconds` long and holds about `max_rows` rows (all
devices together; the rows are taken to be spread evenly over the time).
What the caller could not send goes back with restore().
"""
with self._lock:
if not self._chunks:
return None
chunk = self._chunks.popleft()
self._bytes -= chunk.nbytes
chunk.sealed = True
limit = chunk.seconds if max_seconds is None else max_seconds
if max_rows is not None and chunk.rows > max_rows:
limit = min(limit, max(1, int(chunk.seconds * max_rows / chunk.rows)))
if chunk.seconds > limit:
chunk, rest = _split(chunk, chunk.first + limit)
self._chunks.appendleft(rest)
self._bytes += rest.nbytes
return chunk
|