fleetpull 0.1.0__py3-none-any.whl

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.
Files changed (252) hide show
  1. fleetpull/__init__.py +36 -0
  2. fleetpull/api/__init__.py +32 -0
  3. fleetpull/api/auth_ingress.py +216 -0
  4. fleetpull/api/catalog.py +139 -0
  5. fleetpull/api/fetch.py +229 -0
  6. fleetpull/api/identity.py +90 -0
  7. fleetpull/api/sync.py +735 -0
  8. fleetpull/cli.py +146 -0
  9. fleetpull/config/__init__.py +47 -0
  10. fleetpull/config/base.py +18 -0
  11. fleetpull/config/example.py +90 -0
  12. fleetpull/config/geotab.py +78 -0
  13. fleetpull/config/http.py +32 -0
  14. fleetpull/config/loading.py +195 -0
  15. fleetpull/config/logger.py +139 -0
  16. fleetpull/config/providers.py +520 -0
  17. fleetpull/config/rate_limit.py +50 -0
  18. fleetpull/config/resolution.py +156 -0
  19. fleetpull/config/retry.py +69 -0
  20. fleetpull/config/root.py +157 -0
  21. fleetpull/config/sections.py +146 -0
  22. fleetpull/endpoints/__init__.py +24 -0
  23. fleetpull/endpoints/geotab/__init__.py +12 -0
  24. fleetpull/endpoints/geotab/_requests.py +427 -0
  25. fleetpull/endpoints/geotab/annotation_logs.py +73 -0
  26. fleetpull/endpoints/geotab/audits.py +70 -0
  27. fleetpull/endpoints/geotab/devices.py +79 -0
  28. fleetpull/endpoints/geotab/driver_changes.py +73 -0
  29. fleetpull/endpoints/geotab/duty_status_logs.py +75 -0
  30. fleetpull/endpoints/geotab/dvir_logs.py +76 -0
  31. fleetpull/endpoints/geotab/exception_events.py +119 -0
  32. fleetpull/endpoints/geotab/fault_data.py +72 -0
  33. fleetpull/endpoints/geotab/fill_ups.py +79 -0
  34. fleetpull/endpoints/geotab/fuel_and_energy_used.py +74 -0
  35. fleetpull/endpoints/geotab/fuel_tax_details.py +75 -0
  36. fleetpull/endpoints/geotab/log_records.py +74 -0
  37. fleetpull/endpoints/geotab/media_files.py +74 -0
  38. fleetpull/endpoints/geotab/shipment_logs.py +71 -0
  39. fleetpull/endpoints/geotab/status_data.py +71 -0
  40. fleetpull/endpoints/geotab/text_messages.py +77 -0
  41. fleetpull/endpoints/geotab/trips.py +102 -0
  42. fleetpull/endpoints/geotab/users.py +81 -0
  43. fleetpull/endpoints/motive/__init__.py +12 -0
  44. fleetpull/endpoints/motive/_spec_builders.py +94 -0
  45. fleetpull/endpoints/motive/driver_idle_rollups.py +113 -0
  46. fleetpull/endpoints/motive/driving_periods.py +85 -0
  47. fleetpull/endpoints/motive/groups.py +61 -0
  48. fleetpull/endpoints/motive/idle_events.py +94 -0
  49. fleetpull/endpoints/motive/users.py +64 -0
  50. fleetpull/endpoints/motive/vehicle_locations.py +170 -0
  51. fleetpull/endpoints/motive/vehicle_utilizations.py +122 -0
  52. fleetpull/endpoints/motive/vehicles.py +106 -0
  53. fleetpull/endpoints/registry.py +280 -0
  54. fleetpull/endpoints/samsara/__init__.py +12 -0
  55. fleetpull/endpoints/samsara/_spec_builders.py +202 -0
  56. fleetpull/endpoints/samsara/addresses.py +77 -0
  57. fleetpull/endpoints/samsara/asset_locations.py +217 -0
  58. fleetpull/endpoints/samsara/driver_fuel_energy_reports.py +124 -0
  59. fleetpull/endpoints/samsara/driver_vehicle_assignments.py +211 -0
  60. fleetpull/endpoints/samsara/drivers.py +169 -0
  61. fleetpull/endpoints/samsara/engine_states.py +114 -0
  62. fleetpull/endpoints/samsara/gps_readings.py +113 -0
  63. fleetpull/endpoints/samsara/idling_events.py +195 -0
  64. fleetpull/endpoints/samsara/odometer_readings.py +116 -0
  65. fleetpull/endpoints/samsara/trips.py +217 -0
  66. fleetpull/endpoints/samsara/vehicle_fuel_energy_reports.py +139 -0
  67. fleetpull/endpoints/samsara/vehicles.py +121 -0
  68. fleetpull/endpoints/shared/__init__.py +57 -0
  69. fleetpull/endpoints/shared/base.py +529 -0
  70. fleetpull/endpoints/shared/request_shape.py +227 -0
  71. fleetpull/endpoints/shared/resume.py +74 -0
  72. fleetpull/endpoints/shared/spec_builders.py +59 -0
  73. fleetpull/endpoints/shared/sync_mode.py +159 -0
  74. fleetpull/endpoints/shared/url_paths.py +149 -0
  75. fleetpull/exceptions.py +320 -0
  76. fleetpull/incremental/__init__.py +27 -0
  77. fleetpull/incremental/cursor.py +66 -0
  78. fleetpull/incremental/resolution.py +152 -0
  79. fleetpull/incremental/seed.py +53 -0
  80. fleetpull/incremental/window.py +94 -0
  81. fleetpull/logger/__init__.py +5 -0
  82. fleetpull/logger/setup.py +145 -0
  83. fleetpull/model_contract/__init__.py +6 -0
  84. fleetpull/model_contract/coercions.py +33 -0
  85. fleetpull/model_contract/response.py +45 -0
  86. fleetpull/models/__init__.py +7 -0
  87. fleetpull/models/geotab/__init__.py +166 -0
  88. fleetpull/models/geotab/annotation_log.py +109 -0
  89. fleetpull/models/geotab/audit.py +56 -0
  90. fleetpull/models/geotab/device.py +217 -0
  91. fleetpull/models/geotab/driver_change.py +102 -0
  92. fleetpull/models/geotab/duty_status_log.py +200 -0
  93. fleetpull/models/geotab/dvir_log.py +184 -0
  94. fleetpull/models/geotab/exception_event.py +135 -0
  95. fleetpull/models/geotab/fault_data.py +168 -0
  96. fleetpull/models/geotab/fill_up.py +189 -0
  97. fleetpull/models/geotab/fuel_and_energy_used.py +81 -0
  98. fleetpull/models/geotab/fuel_tax_detail.py +150 -0
  99. fleetpull/models/geotab/log_record.py +68 -0
  100. fleetpull/models/geotab/media_file.py +133 -0
  101. fleetpull/models/geotab/shared.py +221 -0
  102. fleetpull/models/geotab/shipment_log.py +105 -0
  103. fleetpull/models/geotab/status_data.py +108 -0
  104. fleetpull/models/geotab/text_message.py +125 -0
  105. fleetpull/models/geotab/trip.py +162 -0
  106. fleetpull/models/geotab/user.py +187 -0
  107. fleetpull/models/motive/__init__.py +43 -0
  108. fleetpull/models/motive/driver_idle_rollup.py +109 -0
  109. fleetpull/models/motive/driving_period.py +82 -0
  110. fleetpull/models/motive/group.py +49 -0
  111. fleetpull/models/motive/idle_event.py +77 -0
  112. fleetpull/models/motive/shared.py +192 -0
  113. fleetpull/models/motive/user.py +217 -0
  114. fleetpull/models/motive/vehicle.py +162 -0
  115. fleetpull/models/motive/vehicle_location.py +127 -0
  116. fleetpull/models/motive/vehicle_utilization.py +128 -0
  117. fleetpull/models/samsara/__init__.py +108 -0
  118. fleetpull/models/samsara/address.py +149 -0
  119. fleetpull/models/samsara/asset_location.py +131 -0
  120. fleetpull/models/samsara/driver.py +232 -0
  121. fleetpull/models/samsara/driver_fuel_energy_report.py +153 -0
  122. fleetpull/models/samsara/driver_vehicle_assignment.py +168 -0
  123. fleetpull/models/samsara/engine_state.py +92 -0
  124. fleetpull/models/samsara/gps_reading.py +146 -0
  125. fleetpull/models/samsara/idling_event.py +174 -0
  126. fleetpull/models/samsara/odometer_reading.py +90 -0
  127. fleetpull/models/samsara/trip.py +199 -0
  128. fleetpull/models/samsara/vehicle.py +167 -0
  129. fleetpull/models/samsara/vehicle_fuel_energy_report.py +191 -0
  130. fleetpull/network/__init__.py +9 -0
  131. fleetpull/network/auth/__init__.py +15 -0
  132. fleetpull/network/auth/authenticate.py +271 -0
  133. fleetpull/network/auth/manager.py +223 -0
  134. fleetpull/network/auth/models.py +57 -0
  135. fleetpull/network/auth/strategies.py +169 -0
  136. fleetpull/network/classifiers/__init__.py +11 -0
  137. fleetpull/network/classifiers/geotab.py +145 -0
  138. fleetpull/network/classifiers/motive.py +79 -0
  139. fleetpull/network/classifiers/samsara.py +80 -0
  140. fleetpull/network/client/__init__.py +25 -0
  141. fleetpull/network/client/page.py +32 -0
  142. fleetpull/network/client/profile.py +27 -0
  143. fleetpull/network/client/registry.py +103 -0
  144. fleetpull/network/client/registry_base.py +150 -0
  145. fleetpull/network/client/runtime.py +44 -0
  146. fleetpull/network/client/transport.py +381 -0
  147. fleetpull/network/contract/__init__.py +50 -0
  148. fleetpull/network/contract/auth.py +48 -0
  149. fleetpull/network/contract/classifier.py +189 -0
  150. fleetpull/network/contract/envelope_fetcher.py +33 -0
  151. fleetpull/network/contract/envelopes.py +189 -0
  152. fleetpull/network/contract/outcome.py +43 -0
  153. fleetpull/network/contract/page_decoder.py +101 -0
  154. fleetpull/network/contract/request.py +97 -0
  155. fleetpull/network/decoders/__init__.py +40 -0
  156. fleetpull/network/decoders/_window_stamp.py +78 -0
  157. fleetpull/network/decoders/geotab.py +309 -0
  158. fleetpull/network/decoders/motive.py +204 -0
  159. fleetpull/network/decoders/motive_reports.py +114 -0
  160. fleetpull/network/decoders/samsara.py +343 -0
  161. fleetpull/network/decoders/samsara_reports.py +119 -0
  162. fleetpull/network/decoders/single_page.py +55 -0
  163. fleetpull/network/limits/__init__.py +13 -0
  164. fleetpull/network/limits/bucket_math.py +58 -0
  165. fleetpull/network/limits/limiter.py +153 -0
  166. fleetpull/network/limits/registry.py +96 -0
  167. fleetpull/network/posture/__init__.py +6 -0
  168. fleetpull/network/posture/client_options.py +96 -0
  169. fleetpull/network/retry/__init__.py +13 -0
  170. fleetpull/network/retry/decision.py +155 -0
  171. fleetpull/network/tls/__init__.py +5 -0
  172. fleetpull/network/tls/truststore_context.py +35 -0
  173. fleetpull/orchestrator/__init__.py +30 -0
  174. fleetpull/orchestrator/backfill.py +127 -0
  175. fleetpull/orchestrator/batch.py +155 -0
  176. fleetpull/orchestrator/bisection.py +271 -0
  177. fleetpull/orchestrator/drivers.py +350 -0
  178. fleetpull/orchestrator/entry.py +347 -0
  179. fleetpull/orchestrator/executors.py +128 -0
  180. fleetpull/orchestrator/fanout.py +163 -0
  181. fleetpull/orchestrator/feed_drive.py +266 -0
  182. fleetpull/orchestrator/metadata_projection.py +195 -0
  183. fleetpull/orchestrator/outcome.py +53 -0
  184. fleetpull/orchestrator/recording.py +85 -0
  185. fleetpull/orchestrator/resume.py +143 -0
  186. fleetpull/orchestrator/roster_harvest.py +108 -0
  187. fleetpull/orchestrator/roster_refresh.py +363 -0
  188. fleetpull/orchestrator/runner.py +262 -0
  189. fleetpull/orchestrator/shape_resolution.py +221 -0
  190. fleetpull/orchestrator/spine.py +166 -0
  191. fleetpull/orchestrator/streaming.py +153 -0
  192. fleetpull/orchestrator/unit_loop.py +273 -0
  193. fleetpull/orchestrator/watermark_drive.py +455 -0
  194. fleetpull/paths/__init__.py +13 -0
  195. fleetpull/paths/datasets.py +38 -0
  196. fleetpull/paths/partitions.py +41 -0
  197. fleetpull/paths/resolution.py +98 -0
  198. fleetpull/polars_typing/__init__.py +20 -0
  199. fleetpull/py.typed +0 -0
  200. fleetpull/records/__init__.py +19 -0
  201. fleetpull/records/convert.py +53 -0
  202. fleetpull/records/dataframe.py +63 -0
  203. fleetpull/records/event_time.py +70 -0
  204. fleetpull/records/fields.py +190 -0
  205. fleetpull/records/flatten.py +54 -0
  206. fleetpull/records/roster_members.py +74 -0
  207. fleetpull/records/schema.py +73 -0
  208. fleetpull/records/validation.py +62 -0
  209. fleetpull/resources/__init__.py +10 -0
  210. fleetpull/resources/config.example.yaml +222 -0
  211. fleetpull/roster/__init__.py +16 -0
  212. fleetpull/roster/definition.py +46 -0
  213. fleetpull/roster/key.py +40 -0
  214. fleetpull/roster/registry.py +93 -0
  215. fleetpull/state/__init__.py +36 -0
  216. fleetpull/state/cursors.py +417 -0
  217. fleetpull/state/database.py +463 -0
  218. fleetpull/state/migrations.py +430 -0
  219. fleetpull/state/reconcile.py +127 -0
  220. fleetpull/state/rosters.py +159 -0
  221. fleetpull/state/run_ledger.py +572 -0
  222. fleetpull/state/work_units.py +648 -0
  223. fleetpull/storage/__init__.py +30 -0
  224. fleetpull/storage/append.py +187 -0
  225. fleetpull/storage/atomic.py +161 -0
  226. fleetpull/storage/files.py +176 -0
  227. fleetpull/storage/frames.py +98 -0
  228. fleetpull/storage/metadata.py +161 -0
  229. fleetpull/storage/partitioned.py +205 -0
  230. fleetpull/storage/pruning.py +169 -0
  231. fleetpull/storage/read.py +35 -0
  232. fleetpull/storage/result.py +34 -0
  233. fleetpull/storage/single_file.py +130 -0
  234. fleetpull/storage/splitting.py +88 -0
  235. fleetpull/storage/staging.py +178 -0
  236. fleetpull/storage/writers.py +145 -0
  237. fleetpull/timing/__init__.py +21 -0
  238. fleetpull/timing/canon.py +92 -0
  239. fleetpull/timing/clock.py +192 -0
  240. fleetpull/timing/codec.py +122 -0
  241. fleetpull/timing/sleeper.py +65 -0
  242. fleetpull/vocabulary/__init__.py +16 -0
  243. fleetpull/vocabulary/json_types.py +20 -0
  244. fleetpull/vocabulary/provider.py +29 -0
  245. fleetpull/vocabulary/quota_scope.py +47 -0
  246. fleetpull/vocabulary/response_category.py +32 -0
  247. fleetpull-0.1.0.dist-info/METADATA +248 -0
  248. fleetpull-0.1.0.dist-info/RECORD +252 -0
  249. fleetpull-0.1.0.dist-info/WHEEL +5 -0
  250. fleetpull-0.1.0.dist-info/entry_points.txt +2 -0
  251. fleetpull-0.1.0.dist-info/licenses/LICENSE +202 -0
  252. fleetpull-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,187 @@
1
+ # src/fleetpull/storage/append.py
2
+ """The append-log writer: the feed cell's accumulate-only write path.
3
+
4
+ ``FeedAppendWriter`` is the ``(APPEND_LOG, FeedMode)`` cell (DESIGN §3/§4).
5
+ Unlike every other writer, each ``write`` is durable the moment it returns:
6
+ the piece's rows are split by event date and each date's rows land as the
7
+ next-numbered ``part-NNNNN.parquet`` in that date's partition, through the
8
+ atomic temp-then-rename write. That per-write durability is what the feed
9
+ drive's per-page crash order stands on — a page's parquet must be on disk
10
+ before its token commits (§14's I1), so the feed cell cannot defer its bytes
11
+ to ``finalize`` the way the staged and single-file cells do; ``finalize``
12
+ only reports.
13
+
14
+ The cell is append-only in the strictest sense (§14's I3): it writes new
15
+ part files and never reads, rewrites, or deletes an existing one. Part
16
+ numbering is max-existing + 1, scanned per write — sound because parquet
17
+ merge per endpoint is single-writer (DESIGN §5) — and the chosen target is
18
+ still existence-checked before the rename, so a violated single-writer
19
+ assumption clobbers nothing and fails loudly instead.
20
+
21
+ Deliberately NO write-time exact dedup, unlike every other cell: the dataset
22
+ contract is stored-as-emitted (DESIGN §4) — re-emitted versions and a crash
23
+ window's refetched page land as new rows, and the consumer reconciles
24
+ calculated feeds by ``(id, max version)`` and active feeds by ``id``.
25
+ Deduping a crash-window duplicate would require reading and rewriting
26
+ already-landed part files, which is exactly what I3 forbids; within-page
27
+ duplicates are the provider's emission, kept verbatim.
28
+ """
29
+
30
+ import re
31
+ from pathlib import Path
32
+ from typing import Final
33
+
34
+ import polars as pl
35
+
36
+ from fleetpull.exceptions import ProviderResponseError
37
+ from fleetpull.storage.atomic import atomic_write_parquet
38
+ from fleetpull.storage.files import append_part_file, partition_dir
39
+ from fleetpull.storage.result import WriteResult
40
+ from fleetpull.storage.splitting import split_by_date
41
+
42
+ __all__: list[str] = ['FeedAppendWriter']
43
+
44
+ # The strict shape of an append-log part file name. The scan parses numbers
45
+ # out of exactly this shape; anything else matching 'part-*.parquet' in an
46
+ # append-log partition is a foreign file and fails loudly (the corruption
47
+ # stance) rather than silently skewing the numbering.
48
+ _APPEND_PART_PATTERN: Final[re.Pattern[str]] = re.compile(r'part-(\d+)\.parquet\Z')
49
+
50
+
51
+ def _next_part_number(partition_directory: Path) -> int:
52
+ """The next append part number for one partition: max existing + 1.
53
+
54
+ Scans only the given partition directory (never the endpoint tree), so
55
+ the cost is O(parts in this partition). A missing directory — the
56
+ partition's first-ever part — starts at 1.
57
+
58
+ Args:
59
+ partition_directory: The ``date=YYYY-MM-DD`` directory to scan.
60
+
61
+ Returns:
62
+ The next part number (>= 1).
63
+
64
+ Raises:
65
+ ValueError: A ``part-*.parquet`` file in the partition does not match
66
+ the strict ``part-NNNNN.parquet`` shape — a foreign file in an
67
+ append-log partition is a dataset-layout violation, surfaced
68
+ loudly rather than silently skewing the numbering.
69
+
70
+ Side Effects:
71
+ None -- reads directory listings only.
72
+ """
73
+ if not partition_directory.is_dir():
74
+ return 1
75
+ highest = 0
76
+ for part_path in partition_directory.glob('part-*.parquet'):
77
+ match = _APPEND_PART_PATTERN.fullmatch(part_path.name)
78
+ if match is None:
79
+ raise ValueError(
80
+ f'foreign part file in append-log partition: {part_path} '
81
+ f'does not match part-NNNNN.parquet'
82
+ )
83
+ highest = max(highest, int(match.group(1)))
84
+ return highest + 1
85
+
86
+
87
+ class FeedAppendWriter:
88
+ """``feed`` + ``append_log``: append numbered part files, touch nothing else.
89
+
90
+ Each ``write`` lands its piece durably (one new part file per event date
91
+ present), so the feed drive can commit that piece's token immediately
92
+ after; ``finalize`` reports the accumulated counts and writes nothing.
93
+ ``duplicates_dropped`` is always ``0`` — the cell performs no write-time
94
+ dedup by design (stored-as-emitted; the module docstring carries the
95
+ rationale).
96
+ """
97
+
98
+ def __init__(self, target_dir: Path, event_time_column: str) -> None:
99
+ """Bind the writer to an endpoint directory and event-time column.
100
+
101
+ Args:
102
+ target_dir: The endpoint directory holding the ``date=`` partitions.
103
+ event_time_column: The UTC datetime column whose date routes each
104
+ row into its partition.
105
+
106
+ Side Effects:
107
+ None -- nothing is swept or cleared; prior parts are never touched.
108
+ """
109
+ self._target_dir = target_dir
110
+ self._event_time_column = event_time_column
111
+ self._rows_written = 0
112
+ self._files_written = 0
113
+
114
+ def write(self, frame: pl.DataFrame) -> None:
115
+ """Append one piece durably: a new numbered part per event date present.
116
+
117
+ Splits ``frame`` by the UTC date of the event-time column and writes
118
+ each date's rows as that partition's next ``part-NNNNN.parquet``,
119
+ atomically and DURABLY (the durable-rename recipe: the token commit
120
+ is fsynced, so the page it covers must be too -- a power loss never
121
+ persists a token past unwritten data). An empty frame writes nothing (an at-head feed page). The
122
+ write is durable when this returns — the caller may commit the
123
+ piece's feed token immediately after (§14's per-page crash order).
124
+
125
+ Args:
126
+ frame: One fetched piece (one feed page's validated rows).
127
+
128
+ Raises:
129
+ ProviderResponseError: A row's event-time value is null — a
130
+ feed record without its partition key is a provider
131
+ contract violation surfaced loudly BEFORE any part lands
132
+ (never a mid-write stall behind an untyped error).
133
+ ValueError: A foreign ``part-*.parquet`` file skews a partition's
134
+ numbering (from the scan).
135
+ RuntimeError: The scanned next part number already exists on disk
136
+ — the single-writer assumption is violated; refusing keeps the
137
+ append-only invariant (I3) intact instead of clobbering a
138
+ landed part.
139
+
140
+ Side Effects:
141
+ Writes one new part file per event date present, creating
142
+ partition directories as needed. Never modifies an existing file.
143
+ """
144
+ null_dated = frame[self._event_time_column].null_count() if frame.height else 0
145
+ if null_dated:
146
+ raise ProviderResponseError(
147
+ detail=(
148
+ f'{null_dated} feed record(s) carry a null '
149
+ f'{self._event_time_column!r} -- the append-log partition '
150
+ f'key; refusing the page whole before any part lands'
151
+ )
152
+ )
153
+ for partition_date, sub_frame in split_by_date(frame, self._event_time_column):
154
+ part_number = _next_part_number(
155
+ partition_dir(self._target_dir, partition_date)
156
+ )
157
+ target = append_part_file(self._target_dir, partition_date, part_number)
158
+ if target.exists():
159
+ raise RuntimeError(
160
+ f'append-log part collision: {target} already exists -- '
161
+ f'two writers on one endpoint violate the single-writer '
162
+ f'invariant (DESIGN section 5)'
163
+ )
164
+ atomic_write_parquet(sub_frame, target, durable=True)
165
+ self._rows_written += sub_frame.height
166
+ self._files_written += 1
167
+
168
+ def finalize(self) -> WriteResult:
169
+ """Report the accumulated append counts; write nothing.
170
+
171
+ Every piece already landed durably in ``write`` (the per-page crash
172
+ order requires it), so there is nothing left to persist.
173
+
174
+ Returns:
175
+ The write report — rows and part files appended this run,
176
+ ``duplicates_dropped`` always ``0`` (no write-time dedup, by
177
+ design), ``deleted_partitions`` always empty (nothing is ever
178
+ deleted).
179
+
180
+ Side Effects:
181
+ None.
182
+ """
183
+ return WriteResult(
184
+ rows_written=self._rows_written,
185
+ duplicates_dropped=0,
186
+ files_written=self._files_written,
187
+ )
@@ -0,0 +1,161 @@
1
+ # src/fleetpull/storage/atomic.py
2
+ """The atomic file writes: the storage layer's durability primitives.
3
+
4
+ Every layout's every write goes through here. A file write is not atomic -- a
5
+ crash mid-write corrupts the file -- so the content is written to a temp sibling
6
+ and renamed onto the target, which POSIX guarantees is atomic on one filesystem.
7
+ The prior target is untouched until the rename; a crash leaves either the old
8
+ file or the new, never a partial one (DESIGN §5 crash-safety). The temp is
9
+ always cleaned up: on success (already renamed away) or on failure.
10
+ ``atomic_write_parquet`` is the parquet write every dataset writer uses;
11
+ ``atomic_write_text`` is the same temp-then-rename skeleton for the
12
+ ``metadata.json`` projection's document text.
13
+ """
14
+
15
+ import os
16
+ from pathlib import Path
17
+
18
+ import polars as pl
19
+
20
+ from fleetpull.polars_typing import ParquetCompression
21
+ from fleetpull.storage.files import temp_sibling_path
22
+
23
+ __all__: list[str] = ['atomic_write_parquet', 'atomic_write_text']
24
+
25
+
26
+ def _fsync_path(path: Path) -> None:
27
+ """Flush one path's kernel buffers to stable storage.
28
+
29
+ The open/fsync/close block the durable-rename recipe applies to the
30
+ temp file before the rename and to each directory of the durable
31
+ chain after it.
32
+
33
+ Args:
34
+ path: The file or directory to fsync.
35
+
36
+ Raises:
37
+ OSError: The open or fsync failed.
38
+
39
+ Side Effects:
40
+ Issues an ``fsync`` system call.
41
+ """
42
+ descriptor = os.open(path, os.O_RDONLY)
43
+ try:
44
+ os.fsync(descriptor)
45
+ finally:
46
+ os.close(descriptor)
47
+
48
+
49
+ def _durable_directory_chain(directory: Path) -> list[Path]:
50
+ """The directories a durable write must fsync after its rename.
51
+
52
+ Always ``directory`` itself -- the rename lands the file's entry
53
+ there. When the write is about to CREATE missing ancestors (a feed
54
+ page opening a new ``date=`` partition, or the first-ever write of an
55
+ endpoint), each new directory's own entry lives one level up, so the
56
+ chain extends through every missing ancestor to the first
57
+ pre-existing one: a new-directory chain fsynced only at its deepest
58
+ link is NOT durable -- power loss can drop the newly created
59
+ directory (and the file inside it) from the un-fsynced parent while
60
+ a later fsynced commit survives, persisting a cursor past lost data.
61
+
62
+ Must be computed BEFORE the ``mkdir`` that creates the chain, while
63
+ the missing set is still observable.
64
+
65
+ Args:
66
+ directory: The file's parent directory, existing or about to be
67
+ created.
68
+
69
+ Returns:
70
+ ``directory`` first, then each missing ancestor's parent up to
71
+ and including the first pre-existing directory. Just
72
+ ``[directory]`` when it already exists.
73
+ """
74
+ chain = [directory]
75
+ current = directory
76
+ while not current.exists():
77
+ chain.append(current.parent)
78
+ current = current.parent
79
+ return chain
80
+
81
+
82
+ def atomic_write_parquet(
83
+ frame: pl.DataFrame,
84
+ target: Path,
85
+ compression: ParquetCompression = 'snappy',
86
+ *,
87
+ durable: bool = False,
88
+ ) -> None:
89
+ """Write ``frame`` to ``target`` atomically via temp-then-rename.
90
+
91
+ Ensures ``target``'s parent directory exists, writes the frame to a temp
92
+ sibling, then atomically renames it onto ``target``. Never leaves a partial
93
+ ``target``.
94
+
95
+ Args:
96
+ frame: The DataFrame to persist.
97
+ target: The final parquet path.
98
+ compression: Parquet compression codec. ``'snappy'`` by default (fast,
99
+ BigQuery-friendly); a later config surface parameterizes it.
100
+ durable: When True, fsync the temp file before the rename and the
101
+ durable directory chain after it -- the parent, every ancestor
102
+ this write newly created, and the first pre-existing ancestor
103
+ (whose entry for the new chain must also reach stable
104
+ storage). The feed append cell requires it: its token commit
105
+ is fsynced (SQLite), so a power loss must never persist a
106
+ token whose page -- or whose newly created partition
107
+ directory -- the page cache still held. The self-healing
108
+ writers (replace-partition under lookback refetch, snapshot
109
+ rewrite) stay non-durable -- a lost write there is refetched
110
+ by design.
111
+
112
+ Side Effects:
113
+ Creates ``target``'s parent directory; writes and renames files;
114
+ when ``durable``, fsyncs the file and the directory chain.
115
+
116
+ Raises:
117
+ OSError: If the write, rename, or fsync fails (the temp is cleaned
118
+ up first).
119
+ """
120
+ durable_chain = _durable_directory_chain(target.parent) if durable else []
121
+ target.parent.mkdir(parents=True, exist_ok=True)
122
+ temp: Path = temp_sibling_path(target)
123
+ try:
124
+ frame.write_parquet(temp, compression=compression)
125
+ if durable:
126
+ _fsync_path(temp)
127
+ temp.replace(target)
128
+ for directory in durable_chain:
129
+ _fsync_path(directory)
130
+ finally:
131
+ temp.unlink(missing_ok=True)
132
+
133
+
134
+ def atomic_write_text(text: str, target: Path) -> None:
135
+ """Write ``text`` to ``target`` atomically via temp-then-rename.
136
+
137
+ The parquet write's temp-then-rename skeleton applied to document text:
138
+ temp-sibling in the target's own directory, so the rename is
139
+ same-filesystem and atomic; a crash leaves the prior file or the new one,
140
+ never a partial. The target's parent directory is deliberately NOT
141
+ created -- the one caller (the ``metadata.json`` projection) requires an
142
+ absent endpoint directory to surface as ``OSError``, never be papered
143
+ over with a ``mkdir``.
144
+
145
+ Args:
146
+ text: The complete document text to persist, written UTF-8.
147
+ target: The final file path; its parent directory must exist.
148
+
149
+ Raises:
150
+ OSError: The write or rename failed -- including a missing parent
151
+ directory (the temp is cleaned up first).
152
+
153
+ Side Effects:
154
+ Writes and renames files inside ``target``'s directory.
155
+ """
156
+ temp: Path = temp_sibling_path(target)
157
+ try:
158
+ temp.write_text(text, encoding='utf-8')
159
+ temp.replace(target)
160
+ finally:
161
+ temp.unlink(missing_ok=True)
@@ -0,0 +1,176 @@
1
+ # src/fleetpull/storage/files.py
2
+ """Storage file-path construction: the parquet-format-specific paths under an
3
+ endpoint directory.
4
+
5
+ Pure path arithmetic, no filesystem access. The single-layout data file, the
6
+ date-partitioned layout's partition / part / staging paths, and the temp sibling
7
+ used for atomic writes live here -- storage-specific, unlike the shared
8
+ endpoint-directory construction in ``paths``. The temp sibling sits in the
9
+ target's own directory so the rename that follows is same-filesystem and
10
+ therefore atomic.
11
+ """
12
+
13
+ from datetime import date
14
+ from pathlib import Path
15
+ from uuid import uuid4
16
+
17
+ from fleetpull.paths import date_partition_segment
18
+
19
+ __all__: list[str] = [
20
+ 'append_part_file',
21
+ 'data_file',
22
+ 'partition_dir',
23
+ 'partition_part_file',
24
+ 'partition_staging_dir',
25
+ 'partition_staging_shard',
26
+ 'temp_sibling_path',
27
+ ]
28
+
29
+ # The single-layout data file name (DESIGN §3).
30
+ _SINGLE_FILE_NAME: str = 'data.parquet'
31
+
32
+ # The date-partitioned layout's per-partition part file name (DESIGN §3).
33
+ _PART_FILE_NAME: str = 'part.parquet'
34
+
35
+ # The per-partition staging directory and shard suffix (DESIGN §3).
36
+ _STAGING_DIR_NAME: str = 'staging'
37
+
38
+ # The append-log layout's numbered part-file width (DESIGN §3): five digits
39
+ # zero-padded, so directory listings sort chronologically at a glance. Purely
40
+ # cosmetic — the append scan parses the number, never sorts the text — so a
41
+ # partition beyond 99,999 parts widens naturally without breaking anything.
42
+ _APPEND_PART_NUMBER_WIDTH: int = 5
43
+
44
+
45
+ def data_file(endpoint_dir: Path) -> Path:
46
+ """The ``single``-layout data file under an endpoint directory.
47
+
48
+ Args:
49
+ endpoint_dir: The endpoint directory (from ``endpoint_directory``).
50
+
51
+ Returns:
52
+ ``{endpoint_dir}/data.parquet``.
53
+ """
54
+ return endpoint_dir / _SINGLE_FILE_NAME
55
+
56
+
57
+ def partition_dir(endpoint_dir: Path, partition_date: date) -> Path:
58
+ """The date-partition directory for one date under an endpoint directory.
59
+
60
+ The single place the hive ``date=YYYY-MM-DD`` directory path is built;
61
+ ``partition_part_file`` and the prune step both go through it, so the
62
+ structural fact lives once.
63
+
64
+ Args:
65
+ endpoint_dir: The endpoint directory (from ``endpoint_directory``).
66
+ partition_date: The partition's calendar date.
67
+
68
+ Returns:
69
+ ``{endpoint_dir}/date=YYYY-MM-DD``.
70
+
71
+ Side Effects:
72
+ None -- pure path arithmetic; no filesystem access.
73
+ """
74
+ return endpoint_dir / date_partition_segment(partition_date)
75
+
76
+
77
+ def partition_part_file(endpoint_dir: Path, partition_date: date) -> Path:
78
+ """The date-partitioned part file for one date under an endpoint directory.
79
+
80
+ Args:
81
+ endpoint_dir: The endpoint directory (from ``endpoint_directory``).
82
+ partition_date: The partition's calendar date.
83
+
84
+ Returns:
85
+ ``{endpoint_dir}/date=YYYY-MM-DD/part.parquet``.
86
+
87
+ Side Effects:
88
+ None -- pure path arithmetic; no filesystem access.
89
+ """
90
+ return partition_dir(endpoint_dir, partition_date) / _PART_FILE_NAME
91
+
92
+
93
+ def append_part_file(
94
+ endpoint_dir: Path, partition_date: date, part_number: int
95
+ ) -> Path:
96
+ """The append-log layout's numbered part file for one date partition.
97
+
98
+ The path half of the append-only feed cell (DESIGN §3/§4): each feed page
99
+ lands as the next-numbered ``part-NNNNN.parquet`` in the partitions its
100
+ records' event dates route to; the numbering scan lives with the append
101
+ writer (``storage/append.py``), keeping this module pure path arithmetic.
102
+
103
+ Args:
104
+ endpoint_dir: The endpoint directory (from ``endpoint_directory``).
105
+ partition_date: The partition's calendar date.
106
+ part_number: The part's ordinal (>= 1; the writer's scan supplies
107
+ max-existing + 1).
108
+
109
+ Returns:
110
+ ``{endpoint_dir}/date=YYYY-MM-DD/part-NNNNN.parquet``.
111
+
112
+ Side Effects:
113
+ None -- pure path arithmetic; no filesystem access.
114
+ """
115
+ return partition_dir(endpoint_dir, partition_date) / (
116
+ f'part-{part_number:0{_APPEND_PART_NUMBER_WIDTH}d}.parquet'
117
+ )
118
+
119
+
120
+ def partition_staging_dir(endpoint_dir: Path, partition_date: date) -> Path:
121
+ """The staging directory inside one date partition.
122
+
123
+ Holds the per-piece ``.shard`` files a fanned-out write lands before
124
+ compaction folds them into ``part.parquet``. Inside the partition directory
125
+ so staging is co-located with the partition it feeds; the ``.shard`` extension
126
+ on the shards (not ``.parquet``) keeps them out of a hive ``*.parquet`` read.
127
+
128
+ Args:
129
+ endpoint_dir: The endpoint directory (from ``endpoint_directory``).
130
+ partition_date: The partition's calendar date.
131
+
132
+ Returns:
133
+ ``{endpoint_dir}/date=YYYY-MM-DD/staging``.
134
+
135
+ Side Effects:
136
+ None -- pure path arithmetic; no filesystem access.
137
+ """
138
+ return partition_dir(endpoint_dir, partition_date) / _STAGING_DIR_NAME
139
+
140
+
141
+ def partition_staging_shard(endpoint_dir: Path, partition_date: date) -> Path:
142
+ """A unique ``.shard`` path under one date partition's staging directory.
143
+
144
+ Each call returns a fresh uuid-named shard, so the fanned-out writes to one
145
+ date never collide. The ``.shard`` extension keeps the file out of a hive
146
+ ``*.parquet`` read; compaction reads it back by explicit path.
147
+
148
+ Args:
149
+ endpoint_dir: The endpoint directory (from ``endpoint_directory``).
150
+ partition_date: The partition's calendar date.
151
+
152
+ Returns:
153
+ ``{endpoint_dir}/date=YYYY-MM-DD/staging/shard-{uuid}.shard``.
154
+
155
+ Side Effects:
156
+ None -- pure path construction (the uuid makes each call unique).
157
+ """
158
+ return partition_staging_dir(endpoint_dir, partition_date) / (
159
+ f'shard-{uuid4().hex}.shard'
160
+ )
161
+
162
+
163
+ def temp_sibling_path(target: Path) -> Path:
164
+ """A unique temporary path beside ``target`` for an atomic write.
165
+
166
+ Placed in ``target``'s own directory so the follow-up rename stays on one
167
+ filesystem (POSIX guarantees same-filesystem rename atomicity). The unique
168
+ suffix avoids colliding with a stale temp from an earlier interrupted write.
169
+
170
+ Args:
171
+ target: The final file the temp will be renamed onto.
172
+
173
+ Returns:
174
+ A hidden, uniquely-named sibling temp path.
175
+ """
176
+ return target.with_name(f'.{target.name}.{uuid4().hex}.tmp')
@@ -0,0 +1,98 @@
1
+ # src/fleetpull/storage/frames.py
2
+ """Frame operations the write path composes: the exact-duplicate dedup and the
3
+ half-open window-membership predicate.
4
+
5
+ Pure DataFrame helpers. ``drop_exact_duplicates`` is the write-time exact
6
+ dedup (DESIGN §6), and ``dedup_counting`` is the flag-and-count composition
7
+ the replace-partition and single-file writers run on
8
+ their finalized frames, clearing byte-identical rows as a cheap safety net.
9
+ The feed append cell deliberately does NOT (DESIGN §4's stored-as-emitted
10
+ contract and §14's append-only invariant): crash-window and re-emission
11
+ duplicates are the log's honest content, reconciled by the consumer. ``in_window`` is the half-open ``[start, end)``
12
+ membership predicate that defines the window boundary in exactly one place: its
13
+ consumer today is the orchestrator's batch shaping, which keeps a fetch's
14
+ in-window rows before they reach a writer; the future single-file window-clearing
15
+ cells will apply it in both polarities (delete the existing window's rows, keep
16
+ the fresh fetch's in-window rows) from the same rule.
17
+ """
18
+
19
+ import polars as pl
20
+
21
+ from fleetpull.incremental import DateWindow
22
+
23
+ __all__: list[str] = ['dedup_counting', 'drop_exact_duplicates', 'in_window']
24
+
25
+
26
+ def dedup_counting(frame: pl.DataFrame, *, enabled: bool) -> tuple[pl.DataFrame, int]:
27
+ """Apply the write-time exact dedup when enabled, counting what it dropped.
28
+
29
+ The dedup-and-count step every deduping write path runs before
30
+ persisting -- the finalized single file and each compacted partition --
31
+ stated once so the flag handling and the dropped-row arithmetic never
32
+ drift between writers.
33
+
34
+ Args:
35
+ frame: The frame about to be written.
36
+ enabled: The ``storage.drop_exact_duplicates`` switch; ``False``
37
+ passes ``frame`` through byte-for-byte.
38
+
39
+ Returns:
40
+ The frame to write and the count of exact-duplicate rows removed
41
+ (``0`` when disabled or none were found).
42
+ """
43
+ if not enabled:
44
+ return frame, 0
45
+ written = drop_exact_duplicates(frame)
46
+ return written, frame.height - written.height
47
+
48
+
49
+ def drop_exact_duplicates(frame: pl.DataFrame) -> pl.DataFrame:
50
+ """Drop byte-identical duplicate rows, preserving first-occurrence order.
51
+
52
+ Exactness over all columns: two rows collapse only if every value matches.
53
+ Same-key-different-payload rows are deliberately kept -- collapsing those is
54
+ semantic dedup, out of scope (DESIGN §6).
55
+
56
+ Args:
57
+ frame: The frame to dedup.
58
+
59
+ Returns:
60
+ The frame with exact-duplicate rows removed, order preserved.
61
+ """
62
+ return frame.unique(maintain_order=True)
63
+
64
+
65
+ def in_window(event_time_column: str, window: DateWindow) -> pl.Expr:
66
+ """The half-open ``[start, end)`` window-membership predicate for a column.
67
+
68
+ Returns the boolean Polars expression true for rows whose
69
+ ``event_time_column`` falls in ``window`` -- ``>= window.start`` and
70
+ ``< window.end``, the half-open boundary made literal. It is a *predicate*,
71
+ not a filter, so the boundary is defined in exactly one place and "removal"
72
+ stays the caller's concern (DESIGN §4). Its consumer today is the
73
+ orchestrator's batch shaping, which keeps a fetch's in-window rows with
74
+ ``frame.filter(in_window(col, w))``; the future single-file window-clearing
75
+ cells will add the other polarity, ``frame.filter(~in_window(col, w))``, to
76
+ delete a window's rows from the existing on-disk frame.
77
+
78
+ The full ``[start, end)`` predicate, not merely ``>= start``: in steady state
79
+ ``end`` is ``now`` and ``< end`` binds nothing, but a historical backfill
80
+ chunk has a real ``end``, and ``< end`` is what stops an event on a chunk
81
+ boundary from being claimed by both the chunk ending at it and the one
82
+ starting at it. ``window`` already guarantees ``start < end`` at construction,
83
+ so the predicate never sees an inverted range.
84
+
85
+ Args:
86
+ event_time_column: Name of the UTC datetime column to test.
87
+ window: The half-open ``[start, end)`` resume window.
88
+
89
+ Returns:
90
+ A boolean ``pl.Expr`` true for in-window rows; apply it (or its negation)
91
+ with ``DataFrame.filter``.
92
+
93
+ Side Effects:
94
+ None -- builds an expression; evaluates nothing.
95
+ """
96
+ return (pl.col(event_time_column) >= window.start) & (
97
+ pl.col(event_time_column) < window.end
98
+ )