Skip to content

Commit 996cec5

Browse files
authored
[ENG-11517] Update WB pagination to use async/cache metadata (#489)
* minor paginated metadata improvements * use metadata method instead of _children_metadata when iter_children_pages
1 parent a652736 commit 996cec5

4 files changed

Lines changed: 78 additions & 83 deletions

File tree

waterbutler/core/provider.py

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from waterbutler.core.metrics import MetricsRecord
2020
from waterbutler.core import metadata as wb_metadata
2121
from waterbutler.core.cache import CacheableMetadataProviderProxy
22-
from waterbutler.core.utils import ZipStreamGenerator
22+
from waterbutler.core.utils import ZipStreamGenerator, ZipStreamGeneratorPaginated
2323
from waterbutler.core.utils import RequestHandlerContext
2424

2525

@@ -679,17 +679,17 @@ async def revalidate_path(self,
679679
"""
680680
return base.child(path, folder=folder)
681681

682-
async def zip(self, path: wb_path.WaterButlerPath, use_cache: bool = True, **kwargs) -> asyncio.StreamReader:
682+
async def zip(self, path: wb_path.WaterButlerPath, use_cache: bool = True, paginated: bool = False, **kwargs) -> asyncio.StreamReader:
683683
"""Streams a Zip archive of the given folder
684684
685685
:param path: ( :class:`.WaterButlerPath` ) The folder to compress
686-
:param use_cache: ( `bool` ) Whether to prefetch metadata requests for nested folders during zip
686+
:param use_cache: ( :class:`bool` ) Whether to prefetch metadata requests for nested folders during zip,
687+
does not work for paginated metadata requests
688+
:param paginated: ( :class:`bool` ) Whether to use the paginated zip stream generator
687689
"""
688690
provider = CacheableMetadataProviderProxy(self) if use_cache else self
689-
690-
return streams.ZipStreamReader(
691-
await ZipStreamGenerator.create(provider, path, **kwargs)
692-
) # type: ignore
691+
generator = ZipStreamGeneratorPaginated if paginated else ZipStreamGenerator
692+
return streams.ZipStreamReader(generator(provider, path, **kwargs)) # type: ignore
693693

694694
def shares_storage_root(self, other: 'BaseProvider') -> bool:
695695
"""Returns True if ``self`` and ``other`` both point to the same storage root. Used to
@@ -763,6 +763,20 @@ async def metadata(self, path: wb_path.WaterButlerPath, **kwargs) \
763763
"""
764764
raise NotImplementedError
765765

766+
async def iter_children_pages(self, path: wb_path.WaterButlerPath, **kwargs):
767+
"""Yield a folder's children metadata one page at a time.
768+
769+
The default implementation yields a single page containing the entire folder
770+
listing, since most providers return all children in one request. Providers
771+
with server-side pagination should override this to lazily yield successive
772+
pages, so callers (e.g. the zip stream generators) don't hold an entire large
773+
folder listing in memory at once.
774+
775+
:param path: ( :class:`.WaterButlerPath` ) The folder to list
776+
:param kwargs: ( :class:`dict` ) Arguments to be parsed by child classes
777+
"""
778+
yield await self.metadata(path, **kwargs)
779+
766780
@abc.abstractmethod
767781
async def validate_v1_path(self, path: str, **kwargs) -> wb_path.WaterButlerPath:
768782
"""API v1 requires that requests against folder endpoints always end with a slash, and

waterbutler/core/utils.py

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import re
2+
import abc
23
import json
34
import pytz
45
import asyncio
@@ -192,29 +193,49 @@ def make_disposition(filename):
192193
encoded_filename)
193194

194195

195-
class ZipStreamGenerator:
196+
class BaseZipStreamGenerator(abc.ABC):
196197

197-
def __init__(self, provider, parent_path, *metadata_objs, **kwargs):
198+
def __init__(self, provider, root_path, **kwargs):
198199
self.provider = provider
199-
self.parent_path = parent_path
200-
self.remaining = [
201-
(parent_path, metadata)
202-
for metadata in metadata_objs
203-
]
200+
self.root_path = root_path
201+
self.parent_path = root_path.parent if root_path.is_file else root_path
204202
self.kwargs = kwargs
205203

206-
@classmethod
207-
async def create(cls, provider, path, **kwargs):
208-
meta_data = await provider.metadata(path, **kwargs)
209-
if path.is_file:
210-
meta_data = [meta_data]
211-
path = path.parent
212-
return cls(provider, path, *meta_data, **kwargs)
204+
self.remaining = []
205+
self.initialized = False
213206

214207
async def __aiter__(self):
215208
return self
216209

210+
@abc.abstractmethod
211+
async def _initialize(self):
212+
raise NotImplementedError
213+
214+
@abc.abstractmethod
215+
async def __anext__(self):
216+
raise NotImplementedError
217+
218+
219+
class ZipStreamGenerator(BaseZipStreamGenerator):
220+
221+
async def _initialize(self):
222+
if self.initialized:
223+
return
224+
225+
if self.root_path.is_file:
226+
metadata = await self.provider.metadata(self.root_path, **self.kwargs)
227+
self.remaining.append((self.root_path.parent, metadata))
228+
return
229+
230+
items = await self.provider.metadata(self.root_path, **self.kwargs)
231+
self.remaining.extend((self.root_path, item) for item in items)
232+
233+
self.initialized = True
234+
217235
async def __anext__(self):
236+
if not self.initialized:
237+
await self._initialize()
238+
218239
if not self.remaining:
219240
raise StopAsyncIteration
220241
current = self.remaining.pop(0)
@@ -232,23 +253,12 @@ async def __anext__(self):
232253
return path.path.replace(self.parent_path.path, '', 1), await self.provider.download(path)
233254

234255

235-
class ZipStreamGeneratorPaginated:
236-
237-
def __init__(self, provider, root_path, **kwargs):
238-
self.provider = provider
239-
self.parent_path = root_path.parent if root_path.is_file else root_path
240-
self.root_path = root_path
241-
self.kwargs = kwargs
242-
243-
self.remaining = []
244-
self.initialized = False
256+
class ZipStreamGeneratorPaginated(BaseZipStreamGenerator):
245257

246258
async def _initialize(self):
247259
if self.initialized:
248260
return
249261

250-
self.initialized = True
251-
252262
if self.root_path.is_file:
253263
metadata = await self.provider.metadata(
254264
self.root_path,
@@ -261,14 +271,12 @@ async def _initialize(self):
261271
self.root_path,
262272
**self.kwargs,
263273
):
264-
265274
self.remaining.extend(
266275
(self.root_path, item)
267276
for item in page
268277
)
269278

270-
async def __aiter__(self):
271-
return self
279+
self.initialized = True
272280

273281
async def __anext__(self):
274282
if not self.initialized:

waterbutler/providers/osfstorage/provider.py

Lines changed: 19 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -321,10 +321,7 @@ async def zip(self, path: wb_path.WaterButlerPath, **kwargs) -> asyncio.StreamRe
321321
322322
:param path: ( :class:`.WaterButlerPath` ) The folder to compress
323323
"""
324-
kwargs['minimal'] = True
325-
kwargs['limit'] = None
326-
327-
return streams.ZipStreamReader(utils.ZipStreamGeneratorPaginated(self, path, **kwargs)) # type: ignore
324+
return await super().zip(path, **kwargs, minimal=True)
328325

329326
async def metadata(self, path, **kwargs):
330327
if path.identifier is None:
@@ -526,22 +523,18 @@ async def copy(self,
526523
return meta_data, created
527524

528525
async def iter_children_pages(self, path, **kwargs):
529-
url = self.build_url(
530-
path.identifier,
531-
'children',
532-
user_id=self.auth.get('id'),
533-
minimal=kwargs.get('minimal'),
534-
limit=kwargs.get('limit'),
535-
orm=True
536-
)
526+
limit = kwargs.pop('limit', None)
527+
after = None
537528

538529
while True:
539-
page, url = await self._fetch_metadata_page(url, path, **kwargs)
530+
page = await self.metadata(path, limit=limit, after=after, **kwargs)
540531
yield page
541532

542-
if not url:
533+
if not limit or len(page) < int(limit):
543534
break
544535

536+
after = page[-1].raw['id']
537+
545538
# ========== private ==========
546539

547540
async def _item_metadata(self, path, revision=None):
@@ -552,10 +545,20 @@ async def _item_metadata(self, path, revision=None):
552545
)
553546
return OsfStorageFileMetadata((await resp.json()), str(path))
554547

555-
async def _children_metadata(self, path, **kwargs):
548+
async def _children_metadata(self, path, limit=None, after=None, **kwargs):
549+
query = {
550+
'user_id': self.auth.get('id'),
551+
'minimal': kwargs.get('minimal', False),
552+
}
553+
if limit is not None:
554+
query['orm'] = True
555+
query['limit'] = limit
556+
if after is not None:
557+
query['after'] = after
558+
556559
resp = await self.make_signed_request(
557560
'GET',
558-
self.build_url(path.identifier, 'children', user_id=self.auth.get('id'), minimal=kwargs.get('minimal', False)),
561+
self.build_url(path.identifier, 'children', **query),
559562
expects=(200, )
560563
)
561564
resp_json = await resp.json()
@@ -568,36 +571,6 @@ async def _children_metadata(self, path, **kwargs):
568571
ret.append(OsfStorageFileMetadata(item, str(path.child(item['name']))))
569572
return ret
570573

571-
async def _fetch_metadata_page(self, url, path, **kwargs):
572-
resp = await self.make_signed_request(
573-
'GET',
574-
url,
575-
expects=(200,),
576-
)
577-
resp_json = await resp.json()
578-
579-
if kwargs.get('limit') and len(resp_json) < kwargs.get('limit'):
580-
next_url = None
581-
elif not kwargs.get('limit'):
582-
next_url = None
583-
else:
584-
next_url = self.build_url(
585-
path.identifier,
586-
'children',
587-
user_id=self.auth.get('id'),
588-
minimal=kwargs.get('minimal'),
589-
limit=kwargs.get('limit'),
590-
after=resp_json[-1]['id'] if resp_json else None,
591-
orm=True
592-
)
593-
ret = []
594-
for item in resp_json:
595-
if item['kind'] == 'folder':
596-
ret.append(OsfStorageFolderMetadata(item, str(path.child(item['name'], folder=True))))
597-
else:
598-
ret.append(OsfStorageFileMetadata(item, str(path.child(item['name']))))
599-
return ret, next_url
600-
601574
async def _delete_folder_contents(self, path, **kwargs):
602575
"""Delete the contents of a folder. For use against provider root.
603576

waterbutler/server/api/v1/provider/metadata.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,6 @@ async def download_folder_as_zip(self):
131131
self.set_header('Content-Type', 'application/zip')
132132
self.set_header('Content-Disposition', make_disposition(zipfile_name + '.zip'))
133133

134-
result = await self.provider.zip(self.path)
134+
result = await self.provider.zip(self.path, **self.arguments)
135135

136136
await self.write_stream(result)

0 commit comments

Comments
 (0)