Skip to content

Commit df09bc4

Browse files
committed
Switch to layered storage
This substantially alters the in-DB storage format, breaking each tile into layers. In the future this will allow for tiles to be refreshed per-layer, reducing excess work. A tile database will need to be re-created after this change. It also substantially improves SQL building and escaping, along with better test coverage of Storage
1 parent 71a659c commit df09bc4

File tree

11 files changed

+592
-148
lines changed

11 files changed

+592
-148
lines changed

tests/test_config.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ def test_properties(self):
5858
self.assertEqual(c.minzoom, 13)
5959
self.assertEqual(c.maxzoom, 14)
6060

61+
self.assertSequenceEqual([*c.layer_names()], ["building"])
62+
63+
self.assertEqual(c.layer_query("building", Tile(13, 0, 0)),
64+
"WITH mvtgeom AS -- building/13/0/0\n(\n\n)\n"
65+
"SELECT ST_AsMVT(mvtgeom.*, 'building', 4096)\nFROM mvtgeom;")
66+
self.assertEqual(c.layer_query("building", Tile(13, 0, 0)),
67+
c.layer_queries(Tile(13, 0, 0))["building"])
68+
6169
self.assertEqual(c.tilejson("foo"), '''{
6270
"attribution": "attribution",
6371
"bounds": [
@@ -149,6 +157,18 @@ def test_exceptions(self):
149157
self.assertRaises(tilekiln.errors.ConfigYAMLError, Config,
150158
'''metadata: {id: 1}''', fs)
151159

160+
fs.writetext("blank.sql.jinja2", "")
161+
c_str = ('''{"metadata": {"id":"id", '''
162+
'''"name": "name", '''
163+
'''"description":"description", '''
164+
'''"attribution":"attribution", "version": "1.0.0",'''
165+
'''"bounds": [-180, -85, 180, 85], "center": [0, 0]},'''
166+
'''"vector_layers": {"\"":{'''
167+
'''"description": "buildings",'''
168+
'''"fields":{"foo": "bar"},'''
169+
'''"sql": [{"minzoom":13, "maxzoom":14, "file": "blank.sql.jinja2"}]}}}''')
170+
self.assertRaises(tilekiln.errors.ConfigError, Config, c_str, fs)
171+
152172

153173
class TestLayerConfig(TestCase):
154174
def test_render(self):

tests/test_storage.py

Lines changed: 319 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,323 @@
1+
import json
2+
import queue
13
from unittest import TestCase
24

5+
from tilekiln.storage import Storage
6+
from tilekiln.tile import Tile
7+
from tilekiln.metric import Metric
8+
import tilekiln.errors
9+
10+
11+
class FakeCursor:
12+
def __init__(self, calls, rets):
13+
self.calls = calls
14+
self.rets = rets
15+
16+
def execute(self, query, vars=None, binary=None):
17+
try:
18+
self.calls.append(query.as_string())
19+
except AttributeError:
20+
self.calls.append(query)
21+
22+
def __iter__(self):
23+
return self
24+
25+
def __next__(self):
26+
try:
27+
return self.rets.get_nowait()
28+
except queue.Empty:
29+
raise StopIteration
30+
31+
def fetchone(self):
32+
return next(self)
33+
34+
35+
class FakeCursCM:
36+
def __init__(self, calls, rets):
37+
self.curs = FakeCursor(calls, rets)
38+
39+
def __enter__(self):
40+
return self.curs
41+
42+
def __exit__(*args):
43+
pass
44+
45+
46+
class FakeConnection:
47+
def __init__(self, calls, rets):
48+
self.curs = FakeCursCM(calls, rets)
49+
50+
def cursor(self, row_factory=None):
51+
return self.curs
52+
53+
def commit(self):
54+
pass
55+
56+
57+
class FakeConnCM:
58+
def __init__(self, calls, rets):
59+
self.conn = FakeConnection(calls, rets)
60+
61+
def __enter__(self):
62+
return self.conn
63+
64+
def __exit__(*args):
65+
pass
66+
67+
68+
class FakePool:
69+
def __init__(self, calls: list[str], rets: queue.Queue[dict[str, str]]):
70+
self.cm = FakeConnCM(calls, rets)
71+
72+
def connection(self):
73+
return self.cm
74+
375

476
class TestStorage(TestCase):
5-
maxDiff = None
77+
def test_schema(self):
78+
calls = []
79+
rets = queue.SimpleQueue()
80+
pool = FakePool(calls, rets)
81+
82+
storage = Storage(pool)
83+
84+
storage.create_schema()
85+
86+
self.assertRegex(calls[0], r"(?ims)CREATE SCHEMA.*tilekiln")
87+
self.assertRegex(calls[1], r"(?ims)CREATE.*TABLE.*generate_stats.*id.*zoom.*")
88+
self.assertRegex(calls[2], r"(?ims)CREATE.*TABLE.*tile_stats.*id.*zoom.*")
89+
self.assertRegex(calls[3], r"(?ims)CREATE.*TABLE.*metadata.*")
90+
self.assertRegex(calls[3], r"id text")
91+
self.assertRegex(calls[3], r"active boolean")
92+
self.assertRegex(calls[3], r"layers text\[\]")
93+
self.assertRegex(calls[3], r"minzoom smallint")
94+
self.assertRegex(calls[3], r"maxzoom smallint")
95+
calls.clear()
96+
97+
def test_tileset(self):
98+
calls = []
99+
rets = queue.SimpleQueue()
100+
pool = FakePool(calls, rets)
101+
102+
storage = Storage(pool)
103+
104+
storage.create_tileset("foo", ["lyr1", "lyr2"], 0, 2, "{}")
105+
106+
self.assertRegex(calls[0], r"(?ims)INSERT INTO.*metadata.*VALUES.*ON CONFLICT")
107+
self.assertRegex(calls[1], r"(?ims)CREATE TABLE.*foo.*")
108+
self.assertRegex(calls[1], r'''(?ims)"lyr1_generated" timestamptz''')
109+
self.assertRegex(calls[1], r'''(?ims)"lyr1_data" bytea''')
110+
# Check timestamps are before tile data for storage reasons
111+
self.assertRegex(calls[1], r'''(?ims)timestamptz.*bytea''')
112+
self.assertNotRegex(calls[1], r'''(?ims)bytea.*timestamptz''')
113+
114+
self.assertRegex(calls[2], r"(?ims)CREATE TABLE.*foo_z0")
115+
self.assertRegex(calls[2], r"(?ims)PARTITION OF.*foo")
116+
self.assertRegex(calls[2], r"(?ims)FOR VALUES IN \(0\)")
117+
self.assertRegex(calls[3], r"(?ims)CREATE TABLE.*foo_z1")
118+
self.assertRegex(calls[3], r"(?ims)PARTITION OF.*foo")
119+
self.assertRegex(calls[3], r"(?ims)FOR VALUES IN \(1\)")
120+
self.assertRegex(calls[4], r"(?ims)CREATE TABLE.*foo_z2")
121+
self.assertRegex(calls[4], r"(?ims)PARTITION OF.*foo")
122+
self.assertRegex(calls[4], r"(?ims)FOR VALUES IN \(2\)")
123+
calls.clear()
124+
125+
storage.remove_tileset("foo")
126+
127+
self.assertRegex(calls[0], r"(?ims)DELETE FROM.*metadata.*id")
128+
self.assertRegex(calls[1], r"(?ims)DROP TABLE.*foo")
129+
self.assertRegex(calls[2], r"(?ims)DELETE FROM.*tile_stats.*id")
130+
calls.clear()
131+
132+
rets.put({"id": "foo"})
133+
rets.put({"id": "bar"})
134+
135+
ids = storage.get_tileset_ids()
136+
137+
self.assertEqual(next(ids), "foo")
138+
self.assertEqual(next(ids), "bar")
139+
self.assertRegex(calls[0], r"(?ims)SELECT id.*metadata")
140+
141+
calls.clear()
142+
while not rets.empty():
143+
queue.get()
144+
145+
rets.put({"id": "foo",
146+
"layers": ["lyr1", "lyr2"],
147+
"minzoom": 0,
148+
"maxzoom": 2,
149+
"tilejson": json.loads("{}")
150+
})
151+
152+
tilesets = storage.get_tilesets()
153+
tileset = next(tilesets)
154+
self.assertRegex(calls[0], r"(?ims)SELECT id.*metadata")
155+
self.assertEqual(tileset.id, "foo")
156+
self.assertEqual(tileset.layers, ["lyr1", "lyr2"])
157+
self.assertEqual(tileset.minzoom, 0)
158+
self.assertEqual(tileset.maxzoom, 2)
159+
self.assertEqual(tileset.tilejson, '{}')
160+
161+
def test_metadata(self):
162+
calls = []
163+
rets = queue.SimpleQueue()
164+
pool = FakePool(calls, rets)
165+
166+
storage = Storage(pool)
167+
storage.set_metadata("foo", ["lyr1", "lyr2"], 0, 3, "{}")
168+
169+
self.assertRegex(calls[0], r"(?ims)INSERT INTO.*metadata.*VALUES.*ON CONFLICT")
170+
calls.clear()
171+
172+
def test_tiles(self):
173+
calls = []
174+
rets = queue.SimpleQueue()
175+
pool = FakePool(calls, rets)
176+
177+
rets.put({"id": "foo",
178+
"layers": ["lyr1", "lyr2"],
179+
"minzoom": 0,
180+
"maxzoom": 2,
181+
"tilejson": json.loads("{}")
182+
})
183+
rets.put({"lyr1_data": b"bar", "lyr2_data": b"baz", "generated": "datetime"})
184+
storage = Storage(pool)
185+
result, generated = storage.get_tile("foo", Tile(0, 0, 0))
186+
self.assertEqual(result["lyr1"], b"bar")
187+
self.assertEqual(result["lyr2"], b"baz")
188+
self.assertEqual(generated, "datetime")
189+
190+
# calls[0] is get_tileset call tested above. TODO: test it above
191+
self.assertRegex(calls[1],
192+
r"(?ims)SELECT.*lyr1_generated.*.*lyr1_data.*FROM.*foo.*WHERE.*zoom")
193+
194+
calls.clear()
195+
while not rets.empty():
196+
queue.get()
197+
198+
# Test no tile found
199+
rets.put({"id": "foo",
200+
"layers": ["lyr1", "lyr2"],
201+
"minzoom": 0,
202+
"maxzoom": 2,
203+
"tilejson": json.loads("{}")
204+
})
205+
rets.put(None)
206+
self.assertEqual(storage.get_tile("foo", Tile(0, 0, 0)), (None, None))
207+
208+
calls.clear()
209+
while not rets.empty():
210+
queue.get()
211+
212+
rets.put({"id": "foo",
213+
"layers": ["lyr1", "lyr2"],
214+
"minzoom": 0,
215+
"maxzoom": 2,
216+
"tilejson": json.loads("{}")
217+
})
218+
rets.put({"generated": "datetime"})
219+
220+
self.assertEqual(storage.save_tile("foo", Tile(2, 1, 0),
221+
{"lyr1": b"bar", "lyr2": b"baz"}), "datetime")
222+
self.assertRegex(calls[0], r"(?ims)minzoom.*maxzoom")
223+
self.assertRegex(calls[1], r"(?ims)INSERT INTO.*foo_z2")
224+
# Test colums are right
225+
self.assertRegex(calls[1],
226+
r"(?ms)\(zoom[^\)]+x[^\)]+y[^\)]+lyr1_data[^\)]+lyr2_data[^\)]*\)")
227+
self.assertRegex(calls[1],
228+
r'''(?ims)VALUES\s+\(\s*2,\s+1,\s+0,\s+'''
229+
r'''\%\([^\)]*\)s,\s+\%\([^\)]*\)s\s*\)''')
230+
self.assertRegex(calls[1],
231+
r"(?ims)ON CONFLICT\s+\(zoom,\s+x,\s+y\s*\)")
232+
# Test that the upsert sets data to something based on excluded
233+
self.assertRegex(calls[1], r"(?ims)DO UPDATE.*lyr1_data[^,]+=[^,]*EXCLUDED[^,]+lyr1_data")
234+
self.assertRegex(calls[1], r"(?ims)DO UPDATE.*lyr2_data[^,]+=[^,]*EXCLUDED[^,]+lyr2_data")
235+
236+
# test that upserts sets generated to something based on stored and new lyr1_generated,
237+
# statement_timestamp, and that old generated is referenced
238+
self.assertRegex(calls[1],
239+
r"(?ims)DO UPDATE.*lyr1_generated[^,]*=[^,]*STORE\.[^,]*lyr1_data")
240+
self.assertRegex(calls[1],
241+
r"(?ims)DO UPDATE.*lyr1_generated[^,]*=[^,]*EXCLUDED\.[^,]*lyr1_data")
242+
self.assertRegex(calls[1],
243+
r"(?ims)DO UPDATE.*lyr1_generated[^,]+=[^,]*statement_timestamp")
244+
self.assertRegex(calls[1],
245+
r"(?ims)DO UPDATE.*lyr1_generated[^,]+=[^,]*STORE\.[^,]*lyr1_generated")
246+
self.assertRegex(calls[1],
247+
r"(?ims)DO UPDATE.*lyr2_generated[^,]*=[^,]*STORE\.[^,]*lyr2_data")
248+
self.assertRegex(calls[1],
249+
r"(?ims)DO UPDATE.*lyr2_generated[^,]*=[^,]*EXCLUDED\.[^,]*lyr2_data")
250+
self.assertRegex(calls[1],
251+
r"(?ims)DO UPDATE.*lyr2_generated[^,]+=[^,]*statement_timestamp")
252+
self.assertRegex(calls[1],
253+
r"(?ims)DO UPDATE.*lyr2_generated[^,]+=[^,]*STORE\.[^,]*lyr2_generated")
254+
255+
self.assertRegex(calls[1],
256+
r"(?ims)RETURNING.*lyr1_generated")
257+
self.assertRegex(calls[1],
258+
r"(?ims)RETURNING.*lyr2_generated")
259+
260+
calls.clear()
261+
while not rets.empty():
262+
queue.get()
263+
264+
rets.put({"id": "foo",
265+
"layers": ["lyr1", "lyr2"],
266+
"minzoom": 0,
267+
"maxzoom": 2,
268+
"tilejson": json.loads("{}")
269+
})
270+
rets.put({"generated": "datetime"})
271+
272+
# Check that an exception is raised if trying to save a tile with missing layers
273+
self.assertRaises(tilekiln.errors.Error, storage.save_tile, "foo", Tile(2, 1, 0),
274+
{"lyr1": b"bar"}, "datetime")
275+
276+
def test_metrics(self):
277+
calls = []
278+
rets = queue.SimpleQueue()
279+
pool = FakePool(calls, rets)
280+
281+
storage = Storage(pool)
282+
283+
rets.put({"id": "foo",
284+
"zoom": 0,
285+
"num_tiles": 1,
286+
"size": 1024,
287+
"percentiles": [0, 1, 2]})
288+
rets.put({"id": "foo",
289+
"zoom": 1,
290+
"num_tiles": 4,
291+
"size": 4096,
292+
"percentiles": [0, 1, 2]})
293+
metrics = storage.metrics()
294+
self.assertEqual(metrics[0], Metric(id="foo", zoom=0, num_tiles=1,
295+
size=1024, percentiles=[0, 1, 2]))
296+
self.assertEqual(metrics[1], Metric(id="foo", zoom=1, num_tiles=4,
297+
size=4096, percentiles=[0, 1, 2]))
298+
299+
self.assertRegex(calls[0], r"(?ims)SELECT.*id.*zoom.*num_tiles.*size.*percentiles")
300+
self.assertRegex(calls[0], r"(?ims)FROM.*tile_stats")
301+
# update_metrics
302+
303+
calls.clear()
304+
while not rets.empty():
305+
queue.get()
306+
307+
rets.put({"id": "foo",
308+
"layers": ["lyr1", "lyr2"],
309+
"minzoom": 0,
310+
"maxzoom": 1,
311+
"tilejson": json.loads("{}")
312+
})
313+
314+
storage.update_metrics()
315+
316+
# calls 0 and 1 are get_tileset and JIT statements
317+
self.assertRegex(calls[2], r"(?i)INSERT INTO.*tile_stats")
318+
self.assertRegex(calls[2], r'''(?ims)SUM\(length\("?lyr1_data"?\)'''
319+
r'''\+length\("?lyr2_data"?\)\)''')
320+
self.assertRegex(calls[2], r'''(?ims)ARRAY\[.*COALESCE\(PERCENTILE_CONT\(.*\).*\).*\]''')
321+
self.assertRegex(calls[2], r"(?i)FROM.*foo_z0")
322+
# call 3 is JIT
323+
self.assertRegex(calls[4], r"(?i)FROM.*foo_z1")

0 commit comments

Comments
 (0)