File Coverage

blib/lib/Starch/Store/Amazon/DynamoDB.pm
Criterion Covered Total %
statement 30 94 31.9
branch 0 28 0.0
condition 0 6 0.0
subroutine 10 26 38.4
pod 4 4 100.0
total 44 158 27.8


line stmt bran cond sub pod time code
1             package Starch::Store::Amazon::DynamoDB;
2              
3             $Starch::Store::Amazon::DynamoDB::VERSION = '0.05';
4              
5             =head1 NAME
6              
7             Starch::Store::Amazon::DynamoDB - Starch storage backend using Amazon::DynamoDB.
8              
9             =head1 SYNOPSIS
10              
11             my $starch = Starch->new(
12             store => {
13             class => '::Amazon::DynamoDB',
14             ddb => {
15             implementation => 'Amazon::DynamoDB::LWP',
16             version => '20120810',
17            
18             access_key => 'access_key',
19             secret_key => 'secret_key',
20             # or you specify to use an IAM role
21             use_iam_role => 1,
22            
23             host => 'dynamodb.us-east-1.amazonaws.com',
24             scope => 'us-east-1/dynamodb/aws4_request',
25             ssl => 1,
26             },
27             },
28             );
29              
30             =head1 DESCRIPTION
31              
32             This L<Starch> store uses L<Amazon::DynamoDB> to set and get state data.
33              
34             =head1 SERIALIZATION
35              
36             State data is stored in DynamoDB in an odd fashion in order to bypass
37             some of DynamoDB's and L<Amazon::DynamoDB>'s design limitations.
38              
39             =over
40              
41             =item *
42              
43             Empty strings are stored with the value C<__EMPTY__> as DynamoDB does
44             not support empty string values.
45              
46             =item *
47              
48             References are serialized using the L</serializer> and prefixed
49             with C<__SERIALIZED__:>. DynamoDB supports array and hash-like
50             data types, but L<Amazon::DynamoDB> does not.
51              
52             =item *
53              
54             Undefined values are serialized as C<__UNDEF__>, because
55             DynamoDB does not support undefined or null values.
56              
57             =back
58              
59             This funky serialization is only visibile if you look at the raw
60             DynamoDB records. As an example, here's what the
61             L<Starch::State/data> would look like:
62              
63             {
64             this => 'that',
65             thing => { goose=>3 },
66             those => [1,2,3],
67             name => '',
68             age => undef,
69             biography => ' ',
70             }
71              
72             And here's what the record would look like in DynamoDB:
73              
74             this: 'that'
75             thing: '__SERIALIZED__:{"goose":3}'
76             those: '__SERIALIZED__:[1,2,3]'
77             name: '__EMPTY__'
78             age: '__UNDEF__'
79             biography: ' '
80              
81             =cut
82              
83 1     1   213331 use Amazon::DynamoDB;
  1         1268772  
  1         45  
84 1     1   9 use Types::Standard -types;
  1         3  
  1         42  
85 1     1   6274 use Types::Common::String -types;
  1         22323  
  1         23  
86 1     1   1696 use Scalar::Util qw( blessed );
  1         5  
  1         57  
87 1     1   6 use Try::Tiny;
  1         2  
  1         53  
88 1     1   462 use Data::Serializer::Raw;
  1         1246  
  1         40  
89 1     1   478 use Starch::Util qw( croak );
  1         1336  
  1         66  
90              
91 1     1   10 use Moo;
  1         2  
  1         10  
92 1     1   483 use strictures 2;
  1         5  
  1         35  
93 1     1   163 use namespace::clean;
  1         2  
  1         4  
94              
95             with qw(
96             Starch::Store
97             );
98              
99             after BUILD => sub{
100             my ($self) = @_;
101              
102             # Get this loaded as early as possible.
103             $self->ddb();
104              
105             if ($self->connect_on_create()) {
106             $self->get(
107             'starch-store-dynamodb-initialization', [],
108             );
109             }
110              
111             return;
112             };
113              
114             =head1 REQUIRED ARGUMENTS
115              
116             =head2 ddb
117              
118             This must be set to either hash ref arguments for L<Amazon::DynamoDB>
119             or a pre-built object (often retrieved using a method proxy).
120              
121             When configuring Starch from static configuration files using a
122             L<method proxy|Starch/METHOD PROXIES>
123             is a good way to link your existing L<Amazon::DynamoDB> object
124             constructor in with Starch so that starch doesn't build its own.
125              
126             =cut
127              
128             has _ddb_arg => (
129             is => 'ro',
130             isa => (HasMethods[ 'put_item', 'get_item', 'delete_item' ]) | HashRef,
131             init_arg => 'ddb',
132             required => 1,
133             );
134              
135             has ddb => (
136             is => 'lazy',
137             isa => HasMethods[ 'put_item', 'get_item', 'delete_item' ],
138             init_arg => undef,
139             );
140             sub _build_ddb {
141 0     0     my ($self) = @_;
142              
143 0           my $ddb = $self->_ddb_arg();
144 0 0         return $ddb if blessed $ddb;
145              
146 0           return Amazon::DynamoDB->new( %$ddb );
147             }
148              
149             =head1 OPTIONAL ARGUMENTS
150              
151             =head2 consistent_read
152              
153             When C<true> this sets the C<ConsistentRead> flag when calling
154             L<get_item> on the L</ddb>. Defaults to C<true>.
155              
156             =cut
157              
158             has consistent_read => (
159             is => 'ro',
160             isa => Bool,
161             default => 1,
162             );
163              
164             =head2 serializer
165              
166             A L<Data::Serializer::Raw> for serializing the state data for storage
167             when a field's value is a reference. Can be specified as string containing
168             the serializer name, a hashref of Data::Serializer::Raw arguments, or as a
169             pre-created Data::Serializer::Raw object. Defaults to C<JSON>.
170              
171             Consider using the C<JSON::XS> or C<Sereal> serializers for speed.
172              
173             =cut
174              
175             has _serializer_arg => (
176             is => 'ro',
177             isa => ((InstanceOf[ 'Data::Serializer::Raw' ]) | HashRef) | NonEmptySimpleStr,
178             init_arg => 'serializer',
179             default => 'JSON',
180             );
181              
182             has serializer => (
183             is => 'lazy',
184             isa => InstanceOf[ 'Data::Serializer::Raw' ],
185             init_arg => undef,
186             );
187             sub _build_serializer {
188 0     0     my ($self) = @_;
189              
190 0           my $serializer = $self->_serializer_arg();
191 0 0         return $serializer if blessed $serializer;
192              
193 0 0         if (ref $serializer) {
194 0           return Data::Serializer::Raw->new( %$serializer );
195             }
196              
197 0           return Data::Serializer::Raw->new(
198             serializer => $serializer,
199             );
200             }
201              
202             =head2 table
203              
204             The DynamoDB table name where states are stored. Defaults to C<starch_states>.
205              
206             =cut
207              
208             has table => (
209             is => 'ro',
210             isa => NonEmptySimpleStr,
211             default => 'starch_states',
212             );
213              
214             =head2 key_field
215              
216             The field in the L</table> where the state ID is stored.
217             Defaults to C<__STARCH_KEY__>.
218              
219             =cut
220              
221             has key_field => (
222             is => 'ro',
223             isa => NonEmptySimpleStr,
224             default => '__STARCH_KEY__',
225             );
226              
227             =head2 expiration_field
228              
229             The field in the L</table> which will hold the epoch
230             time when the state should be expired. Defaults to C<__STARCH_EXPIRATION__>.
231              
232             =cut
233              
234             has expiration_field => (
235             is => 'ro',
236             isa => NonEmptySimpleStr,
237             default => '__STARCH_EXPIRATION__',
238             );
239              
240             =head2 connect_on_create
241              
242             By default when this store is first created it will issue a L</get>.
243             This initializes all the LWP and other code so that, in a forked
244             environment (such as a web server) this initialization only happens
245             once, not on every child's first request, which otherwise would add
246             about 50 to 100 ms to the firt request of every child.
247              
248             Set this to false if you don't want this feature, defaults to C<true>.
249              
250             =cut
251              
252             has connect_on_create => (
253             is => 'ro',
254             isa => Bool,
255             default => 1,
256             );
257              
258             =head1 METHODS
259              
260             =head2 create_table_args
261              
262             Returns the appropriate arguments to use for calling C<create_table>
263             on the L</ddb> object. By default it will look like this:
264              
265             {
266             TableName => 'starch_states',
267             ReadCapacityUnits => 10,
268             WriteCapacityUnits => 10,
269             AttributeDefinitions => { key => 'S' },
270             KeySchema => [ 'key' ],
271             }
272              
273             Any arguments you pass will override those in the returned arguments.
274              
275             =cut
276              
277             sub create_table_args {
278 0     0 1   my $self = shift;
279              
280 0           my $key_field = $self->key_field();
281              
282             return {
283 0           TableName => $self->table(),
284             ReadCapacityUnits => 10,
285             WriteCapacityUnits => 10,
286             AttributeDefinitions => {
287             $key_field => 'S',
288             },
289             KeySchema => [ $key_field ],
290             @_,
291             };
292             }
293              
294             =head2 create_table
295              
296             Creates the L</table> by passing any arguments to L</create_table_args>
297             and issuing the C<create_table> command on the L</ddb> object.
298              
299             =cut
300              
301             sub create_table {
302 0     0 1   my $self = shift;
303              
304 0           my $args = $self->create_table_args( @_ );
305              
306 0           my $f = $self->ddb->create_table( %$args );
307              
308 0           my $create_errored;
309 0     0     try { $f->get() }
310 0     0     catch { $self->_throw_ddb_error( 'create_table', $_ ); $create_errored=1 };
  0            
  0            
311              
312 0 0         return if $create_errored;
313              
314             $f = $self->ddb->wait_for_table_status(
315             TableName => $args->{TableName},
316 0           );
317              
318 0     0     try { $f->get() }
319 0     0     catch { $self->_throw_ddb_error( 'wait_for_table_status', $_ ) };
  0            
320              
321 0           return;
322             }
323              
324             sub _throw_ddb_error {
325 0     0     my ($self, $method, $error) = @_;
326              
327 0           local $Carp::Internal{ (__PACKAGE__) } = 1;
328              
329 0           my $context = "Amazon::DynamoDB::$method";
330              
331 0 0 0       if (!ref $error) {
    0          
332 0 0         $error = 'UNDEFINED' if !defined $error;
333 0           croak "$context Unknown Error: $error";
334             }
335              
336             elsif (ref($error) eq 'HASH' and defined($error->{message})) {
337 0 0         if (defined($error->{type})) {
338 0           croak "$context: $error->{type}: $error->{message}";
339             }
340             else {
341 0           croak "$context: $error->{message}";
342             }
343             }
344              
345 0           require Data::Dumper;
346 0           croak "$context Unknown Error: " . Data::Dumper::Dumper( $error );
347             }
348              
349             =head2 set
350              
351             Set L<Starch::Store/set>.
352              
353             =head2 get
354              
355             Set L<Starch::Store/get>.
356              
357             =head2 remove
358              
359             Set L<Starch::Store/remove>.
360              
361             =cut
362              
363             sub set {
364             my ($self, $id, $namespace, $data, $expires) = @_;
365              
366             local $Carp::Internal{ (__PACKAGE__) } = 1;
367              
368             $expires += time() if $expires;
369              
370             my $serializer = $self->serializer();
371              
372             $data = {
373             map {
374             ref( $data->{$_} )
375             ? ($_ => '__SERIALIZED__:' . $serializer->serialize( $data->{$_} ))
376             : (
377             (!defined($data->{$_}))
378             ? ($_ => '__UNDEF__')
379             : (
380             ($data->{$_} eq '')
381             ? ($_ => '__EMPTY__')
382             : ($_ => $data->{$_})
383             )
384             )
385             }
386             keys( %$data )
387             };
388              
389             my $key = $self->stringify_key( $id, $namespace );
390              
391             my $f = $self->ddb->put_item(
392             TableName => $self->table(),
393             Item => {
394             $self->key_field() => $key,
395             $self->expiration_field() => $expires,
396             %$data,
397             },
398             );
399              
400             try { $f->get() }
401             catch { $self->_throw_ddb_error( 'put_item', $_ ) };
402              
403             return;
404             }
405              
406             sub get {
407 0     0 1   my ($self, $id, $namespace) = @_;
408              
409 0           local $Carp::Internal{ (__PACKAGE__) } = 1;
410              
411 0           my $key = $self->stringify_key( $id, $namespace );
412              
413 0           my $data;
414             my $f = $self->ddb->get_item(
415 0     0     sub{ $data = shift },
416 0 0         TableName => $self->table(),
417             Key => {
418             $self->key_field() => $key,
419             },
420             ConsistentRead => ($self->consistent_read() ? 'true' : 'false'),
421             );
422              
423 0     0     try { $f->get() }
424 0     0     catch { $self->_throw_ddb_error( 'get_item', $_ ) };
  0            
425              
426 0 0         return undef if !$data;
427              
428 0           my $expiration = delete $data->{ $self->expiration_field() };
429 0 0 0       if ($expiration and $expiration < time()) {
430 0           $self->remove( $id, $namespace );
431 0           return undef;
432             }
433              
434 0           delete $data->{ $self->key_field() };
435              
436 0           my $serializer = $self->serializer();
437              
438             return {
439             map {
440 0           ($data->{$_} =~ m{^__SERIALIZED__:(.*)$})
441             ? ($_ => $serializer->deserialize($1))
442             : (
443             ($data->{$_} eq '__UNDEF__')
444             ? ($_ => undef)
445             : (
446             ($data->{$_} eq '__EMPTY__')
447             ? ($_ => '')
448 0 0         : ($_ => $data->{$_})
    0          
    0          
449             )
450             )
451             }
452             keys( %$data )
453             };
454             }
455              
456             sub remove {
457 0     0 1   my ($self, $id, $namespace) = @_;
458              
459 0           local $Carp::Internal{ (__PACKAGE__) } = 1;
460              
461 0           my $key = $self->stringify_key( $id, $namespace );
462              
463 0           my $f = $self->ddb->delete_item(
464             TableName => $self->table(),
465             Key => {
466             $self->key_field() => $key,
467             },
468             );
469              
470 0     0     try { $f->get() }
471 0     0     catch { $self->_throw_ddb_error( 'delete_item', $_ ) };
  0            
472              
473 0           return;
474             }
475              
476             1;
477             __END__
478              
479             =head1 SUPPORT
480              
481             Please submit bugs and feature requests to the
482             Starch-Store-Amazon-DynamoDB GitHub issue tracker:
483              
484             L<https://github.com/bluefeet/Starch-Store-Amazon-DynamoDB/issues>
485              
486             =head1 AUTHOR
487              
488             Aran Clary Deltac <bluefeetE<64>gmail.com>
489              
490             =head1 ACKNOWLEDGEMENTS
491              
492             Thanks to L<ZipRecruiter|https://www.ziprecruiter.com/>
493             for encouraging their employees to contribute back to the open
494             source ecosystem. Without their dedication to quality software
495             development this distribution would not exist.
496              
497             =head1 LICENSE
498              
499             This library is free software; you can redistribute it and/or modify
500             it under the same terms as Perl itself.
501              
502             =cut
503