Skip to content

Commit fe064d2

Browse files
author
Stephen Pimentel
committed
Initial commit of Bulk Loading layer: bulk.py
1 parent 93b3a52 commit fe064d2

1 file changed

Lines changed: 397 additions & 0 deletions

File tree

lib/bulk.py

Lines changed: 397 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,397 @@
1+
''' FoundationDB Bulk Loader Layer.
2+
3+
Provides a BulkLoader class for loading external datasets to FoundationDB. The
4+
layer assumes that the loading operation has no requirements for atomicity or
5+
isolation.
6+
7+
The layer is designed to be extensibly via subclasses of BulkLoader that handle
8+
external data sources and internal data models. Example subclasses are supplied
9+
for:
10+
11+
- Reading CSV
12+
- Reading JSON
13+
- Reading Blobs
14+
- Writing key-value pairs
15+
- Writing SimpleDoc
16+
- Writing Blobs
17+
18+
'''
19+
20+
import csv
21+
import glob
22+
import json
23+
import numbers
24+
import os
25+
import os.path
26+
27+
import gevent
28+
from gevent.queue import Queue, Empty
29+
30+
import blob
31+
import fdb
32+
import fdb.tuple
33+
import simpledoc
34+
35+
fdb.api_version(22)
36+
37+
db = fdb.open(event_model="gevent")
38+
39+
#####################################
40+
## This defines a Subspace of keys ##
41+
#####################################
42+
43+
class Subspace (object):
44+
def __init__(self, prefixTuple, rawPrefix=""):
45+
self.rawPrefix = rawPrefix + fdb.tuple.pack(prefixTuple)
46+
def __getitem__(self, name):
47+
return Subspace( (name,), self.rawPrefix )
48+
def key(self):
49+
return self.rawPrefix
50+
def pack(self, tuple):
51+
return self.rawPrefix + fdb.tuple.pack( tuple )
52+
def unpack(self, key):
53+
assert key.startswith(self.rawPrefix)
54+
return fdb.tuple.unpack(key[len(self.rawPrefix):])
55+
def range(self, tuple=()):
56+
p = fdb.tuple.range( tuple )
57+
return slice(self.rawPrefix + p.start, self.rawPrefix + p.stop)
58+
59+
60+
@fdb.transactional
61+
def clear_subspace(tr, subspace):
62+
tr.clear_range_startswith(subspace.key())
63+
64+
65+
##############################
66+
## Base class for the layer ##
67+
##############################
68+
69+
class BulkLoader(Queue):
70+
'''
71+
Supports the use of multiple concurrent transactions for efficiency, with a
72+
default of 50 concurrent transactions.
73+
'''
74+
def __init__(self, number_producers=1, number_consumers=50, **kwargs):
75+
# Setting maxsize to the number of consumers will make producers
76+
# wait to put a task in the queue until some consumer is free
77+
super(BulkLoader, self).__init__(maxsize=number_consumers)
78+
self._number_producers = number_producers
79+
self._number_consumers = number_consumers
80+
self._kwargs = kwargs # To be used by reader and writer in subclasses
81+
82+
def _producer(self):
83+
# put will block if maxsize of queue is reached
84+
for data in self.reader(): self.put(data)
85+
86+
def _consumer(self):
87+
try:
88+
while True:
89+
data = self.get(block=False)
90+
self.writer(db, data)
91+
gevent.sleep(0) # yield
92+
except Empty: pass
93+
94+
def produce_and_consume(self):
95+
producers = [gevent.spawn(self._producer) for _ in xrange(self._number_producers)]
96+
consumers = [gevent.spawn(self._consumer) for _ in xrange(self._number_consumers)]
97+
gevent.joinall(producers)
98+
gevent.joinall(consumers)
99+
100+
# Interface stub to be overridden by a subclass. Method should be a
101+
# generator that yields data in increments of size appropriate to be written
102+
# by a single transaction.
103+
def reader(self):
104+
return (i for i in range(5))
105+
106+
# Interface stub to be overridden by a subclass. Method should ideally be
107+
# designed to write around 10kB of data per transaction.
108+
@fdb.transactional
109+
def writer(self, tr, data):
110+
print "Would write", data
111+
112+
113+
def test_loader():
114+
tasks = BulkLoader(1, 5)
115+
tasks.produce_and_consume()
116+
117+
118+
####################
119+
## Reader classes ##
120+
####################
121+
122+
class ReadCSV(BulkLoader):
123+
'''
124+
Reads CSV files. Supported keyword arguments are:
125+
126+
filename=<filename>. Specifies the csv file to read. If no filename is
127+
given, then all files in the specified dir will be read.
128+
129+
dir=<path>. Specifies the directory of the file(s) to be read. Defaults to
130+
current working directory.
131+
132+
delimiter=<string>. Defaults to ','. Use '\t' for a tsv file.
133+
134+
skip_empty=<bool>. If True, skip empty fields in csv files. Otherwise, replace empty
135+
fields with the empty string (''). Default is False.
136+
137+
header=<bool>. If True, assume the first line of csv files consists of field
138+
names and skip it. Otherwise, treat the first line as data to be read.
139+
Default is False.
140+
'''
141+
def __init__(self, number_producers=1, number_consumers=50, **kwargs):
142+
super(ReadCSV, self).__init__(number_producers, number_consumers, **kwargs)
143+
self._filename = kwargs.get('filename', '*')
144+
self._delimiter = kwargs.get('delimiter', ',')
145+
self._dir = kwargs.get('dir', os.getcwd())
146+
self._skip_empty = kwargs.get('skip_empty', False)
147+
self._header = kwargs.get('header', False)
148+
149+
def reader(self):
150+
for fully_pathed in glob.iglob(os.path.join(self._dir, self._filename)):
151+
if not os.path.isfile(fully_pathed): continue
152+
with open(fully_pathed, 'rb') as csv_file:
153+
csv_reader = csv.reader(csv_file, delimiter=self._delimiter)
154+
first_line = True
155+
for line in csv_reader:
156+
if self._header and first_line:
157+
first_line = False
158+
continue
159+
if self._skip_empty:
160+
line = [v for v in line if v != '']
161+
yield tuple(line)
162+
163+
164+
class ReadJSON(BulkLoader):
165+
'''
166+
Reads JSON files. Assumes there is one JSON object per file. Supported
167+
keyword arguments for initialization are:
168+
169+
filename=<filename>. Specifies the json file to read. If no filename is
170+
given, then all files in the specified dir will be read.
171+
172+
dir=<path>. Specifies the directory of the file(s) to be read. Defaults to
173+
current working directory.
174+
175+
convert_unicode=<bool>. If True, returns byte strings rather than unicode
176+
in the deserialized object. Default is True.
177+
178+
convert_numbers=<bool>. If True, returns byte strings rather than numbers or
179+
unicode in the deserialized object. Default is False.
180+
'''
181+
def __init__(self, number_producers=1, number_consumers=50, **kwargs):
182+
super(ReadJSON, self).__init__(number_producers, number_consumers, **kwargs)
183+
self._filename = kwargs.get('filename', '*')
184+
self._dir = kwargs.get('dir', os.getcwd())
185+
self._convert_unicode = kwargs.get('convert_unicode', True)
186+
self._convert_numbers = kwargs.get('convert_numbers', False)
187+
188+
def _convert(self, input, number=False):
189+
if isinstance(input, dict):
190+
return {self._convert(key, number): self._convert(value, number)
191+
for key, value in input.iteritems()}
192+
elif isinstance(input, list):
193+
return [self._convert(element, number) for element in input]
194+
elif isinstance(input, unicode):
195+
return input.encode('utf-8')
196+
elif number and isinstance(input, numbers.Number):
197+
return str(input).encode('utf-8')
198+
else:
199+
return input
200+
201+
def reader(self):
202+
for fully_pathed in glob.iglob(os.path.join(self._dir, self._filename)):
203+
if not os.path.isfile(fully_pathed): continue
204+
with open(fully_pathed, 'r') as json_file:
205+
if self._convert_numbers:
206+
json_object = json.load(json_file,
207+
object_hook=lambda x: self._convert(x, True))
208+
elif self._convert_unicode:
209+
json_object = json.load(json_file,
210+
object_hook=self._convert)
211+
else:
212+
json_object = json.load(json_file)
213+
yield json_object
214+
215+
216+
class ReadBlob(BulkLoader):
217+
'''
218+
Reads files, treating data in each file as a single blob. Supported
219+
keyword arguments for initialization are:
220+
221+
filename=<filename>. Specifies the blob file to read. If no filename is
222+
given, the reader will look for a file in the specified directory, but
223+
any file found must be unique.
224+
225+
dir=<path>. Specifies the directory of the file(s) to be read. Defaults to
226+
current working directory.
227+
228+
chunk_size=<int>. Number of bytes to read from file. Default is 10240.
229+
'''
230+
def __init__(self, number_producers=1, number_consumers=50, **kwargs):
231+
super(ReadBlob, self).__init__(number_producers, number_consumers, **kwargs)
232+
self._filename = kwargs.get('filename', '*')
233+
self._dir = kwargs.get('dir', os.getcwd())
234+
self._chunk_size = kwargs.get('chunk_size', 10240)
235+
236+
def reader(self):
237+
files_found = list(glob.iglob(os.path.join(self._dir, self._filename)))
238+
if len(files_found) != 1: raise Exception("Must specify single file")
239+
fully_pathed = files_found[0]
240+
if not os.path.isfile(fully_pathed): raise Exception("No file found")
241+
with open(fully_pathed, 'rb') as blob_file:
242+
file_size = os.stat(fully_pathed).st_size;
243+
position = 0
244+
while (position < file_size):
245+
try:
246+
chunk = blob_file.read(self._chunk_size)
247+
if not chunk: break;
248+
offset = position
249+
position += self._chunk_size
250+
yield offset, chunk
251+
except IOError as e:
252+
print "I/O error({0}): {1}".format(e.errno, e.strerror)
253+
254+
255+
####################
256+
## Writer classes ##
257+
####################
258+
259+
class WriteKVP(BulkLoader):
260+
'''
261+
Writes key-value pairs directly from tuples. Supported keyword arguments for
262+
initialization are:
263+
264+
empty_value=<bool>. If True, uses all tuple elements to form the key and sets
265+
the value to ''. Otherwise, uses the last element as the value and all
266+
others to form the key. Default is False.
267+
268+
subspace=<Subspace()>. Specifies the subspace to which data is written.
269+
Default is Subspace(('bulk_kvp',)).
270+
271+
clear=<bool>. If True, clears the specified subspace before writing to it.
272+
Default is False.
273+
'''
274+
def __init__(self, number_producers=1, number_consumers=50, **kwargs):
275+
super(WriteKVP, self).__init__(number_producers, number_consumers, **kwargs)
276+
self._empty_value = kwargs.get('empty_value', False)
277+
self._subspace = kwargs.get('subspace', Subspace(('bulk_kvp',)))
278+
self._clear = kwargs.get('clear', False)
279+
if self._clear: clear_subspace(db, self._subspace)
280+
281+
@fdb.transactional
282+
def writer(self, tr, data):
283+
if self._empty_value:
284+
tr[self._subspace.pack(data)] = ''
285+
else:
286+
tr[self._subspace.pack(data[:-1])] = data[-1]
287+
288+
289+
class WriteDoc(BulkLoader):
290+
'''
291+
Writes document-oriented data into a SimpleDoc database. Data must be a
292+
a JSON object without JSON arrays. Supported keyword arguments for
293+
initialization are:
294+
295+
clear=<bool>. If True, clears the specified SimpleDoc object before writing
296+
to it. Default is False.
297+
298+
document=<Doc()>. Specifies the SimpleDoc object to which data is written.
299+
Can be used to load a specified collection or arbitrary subdocument.
300+
Defaults to root.
301+
'''
302+
def __init__(self, number_producers=1, number_consumers=50, **kwargs):
303+
super(WriteDoc, self).__init__(number_producers, number_consumers, **kwargs)
304+
self._document = kwargs.get('document', simpledoc.root)
305+
self._clear = kwargs.get('clear', False)
306+
if self._clear: _simpledoc_clear(db, self._document)
307+
308+
def writer(self, tr, data):
309+
_writer_doc(db, self._document, data)
310+
311+
312+
# @simpledoc.transaction is not signature-preserving and so is used with
313+
# functions rather than methods.
314+
315+
@simpledoc.transactional
316+
def _simpledoc_clear(document):
317+
document.clear_all()
318+
319+
320+
def no_arrays(input):
321+
if isinstance(input, dict):
322+
return all(no_arrays(value) for value in input.itervalues())
323+
elif isinstance(input, list):
324+
return False
325+
else:
326+
return True
327+
328+
329+
@simpledoc.transactional
330+
def _writer_doc(document, data):
331+
assert no_arrays(data), 'JSON object contains arrays'
332+
document.update(data)
333+
334+
335+
class WriteBlob(BulkLoader):
336+
'''
337+
Writes data as a blob using the Blob layer. Supported keyword arguments for
338+
initialization are:
339+
340+
clear=<bool>. If True, clears the specified Blob object before writing
341+
to it. Default is False.
342+
343+
blob=<Doc()>. Specifies the Blob object to which data is written. Default is
344+
Blob(Subspace('bulk_blob',)).
345+
'''
346+
def __init__(self, number_producers=1, number_consumers=50, **kwargs):
347+
super(WriteBlob, self).__init__(number_producers, number_consumers, **kwargs)
348+
self._blob = kwargs.get('blob', blob.Blob(Subspace(('bulk_blob',))))
349+
self._clear = kwargs.get('clear', False)
350+
if self._clear: self._blob.delete(db)
351+
352+
@fdb.transactional
353+
def writer(self, tr, data):
354+
offset = data[0]
355+
chunk = data[1]
356+
self._blob.write(tr, offset, chunk)
357+
358+
359+
##################################
360+
## Combined readers and writers ##
361+
##################################
362+
363+
class CSVtoKVP(ReadCSV, WriteKVP):
364+
def __init__(self, number_producers=1, number_consumers=50, **kwargs):
365+
super(CSVtoKVP, self).__init__(number_producers, number_consumers, **kwargs)
366+
367+
368+
class JSONtoDoc(ReadJSON, WriteDoc):
369+
def __init__(self, number_producers=1, number_consumers=50, **kwargs):
370+
super(JSONtoDoc, self).__init__(number_producers, number_consumers, **kwargs)
371+
372+
373+
class BlobToBlob(ReadBlob, WriteBlob):
374+
def __init__(self, number_producers=1, number_consumers=50, **kwargs):
375+
super(BlobToBlob, self).__init__(number_producers, number_consumers, **kwargs)
376+
377+
378+
'''
379+
The following functions illustrate the format for using the combined subclasses:
380+
381+
382+
def test_csv_kvp():
383+
tasks = CSVtoKVP(1, 5, dir='CSVDir', subspace=Subspace(('bar',)), clear=True)
384+
tasks.produce_and_consume()
385+
386+
387+
def test_json_doc():
388+
tasks = JSONtoDoc(1, 5, dir='PetsDir', clear=True, document=simpledoc.root.animals)
389+
tasks.produce_and_consume()
390+
391+
392+
def test_blob_blob():
393+
my_blob = blob.Blob(Subspace(('my_blob',)))
394+
tasks = BlobToBlob(1, 5, dir='BlobDir', filename='hamlet.txt', blob=my_blob)
395+
tasks.produce_and_consume()
396+
397+
'''

0 commit comments

Comments
 (0)