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