File Coverage

blib/lib/Starch/Store/Amazon/DynamoDB.pm
Criterion Covered Total %
statement 32 93 34.4
branch 0 28 0.0
condition 0 6 0.0
subroutine 11 27 40.7
pod 4 4 100.0
total 47 158 29.7


line stmt bran cond sub pod time code
1             package Starch::Store::Amazon::DynamoDB;
2 1     1   212679 use 5.014000;
  1         8  
3 1     1   8 use strictures 2;
  1         8  
  1         56  
4             our $VERSION = '0.06';
5              
6             =head1 NAME
7              
8             Starch::Store::Amazon::DynamoDB - Starch storage backend using Amazon::DynamoDB.
9              
10             =head1 SYNOPSIS
11              
12             my $starch = Starch->new(
13             store => {
14             class => '::Amazon::DynamoDB',
15             ddb => {
16             implementation => 'Amazon::DynamoDB::LWP',
17             version => '20120810',
18            
19             access_key => 'access_key',
20             secret_key => 'secret_key',
21             # or you specify to use an IAM role
22             use_iam_role => 1,
23            
24             host => 'dynamodb.us-east-1.amazonaws.com',
25             scope => 'us-east-1/dynamodb/aws4_request',
26             ssl => 1,
27             },
28             },
29             );
30              
31             =head1 DESCRIPTION
32              
33             This L<Starch> store uses L<Amazon::DynamoDB> to set and get state data.
34              
35             =head1 SERIALIZATION
36              
37             State data is stored in DynamoDB in an odd fashion in order to bypass
38             some of DynamoDB's and L<Amazon::DynamoDB>'s design limitations.
39              
40             =over
41              
42             =item *
43              
44             Empty strings are stored with the value C<__EMPTY__> as DynamoDB does
45             not support empty string values.
46              
47             =item *
48              
49             References are serialized using the L</serializer> and prefixed
50             with C<__SERIALIZED__:>. DynamoDB supports array and hash-like
51             data types, but L<Amazon::DynamoDB> does not.
52              
53             =item *
54              
55             Undefined values are serialized as C<__UNDEF__>, because
56             DynamoDB does not support undefined or null values.
57              
58             =back
59              
60             This funky serialization is only visibile if you look at the raw
61             DynamoDB records. As an example, here's what the
62             L<Starch::State/data> would look like:
63              
64             {
65             this => 'that',
66             thing => { goose=>3 },
67             those => [1,2,3],
68             name => '',
69             age => undef,
70             biography => ' ',
71             }
72              
73             And here's what the record would look like in DynamoDB:
74              
75             this: 'that'
76             thing: '__SERIALIZED__:{"goose":3}'
77             those: '__SERIALIZED__:[1,2,3]'
78             name: '__EMPTY__'
79             age: '__UNDEF__'
80             biography: ' '
81              
82             =cut
83              
84 1     1   691 use Amazon::DynamoDB;
  1         1263388  
  1         47  
85 1     1   10 use Types::Standard -types;
  1         3  
  1         12  
86 1     1   6060 use Types::Common::String -types;
  1         22470  
  1         17  
87 1     1   1785 use Scalar::Util qw( blessed );
  1         3  
  1         121  
88 1     1   9 use Try::Tiny;
  1         2  
  1         62  
89 1     1   519 use Data::Serializer::Raw;
  1         1190  
  1         42  
90 1     1   436 use Starch::Util qw( croak );
  1         3202  
  1         68  
91              
92 1     1   8 use Moo;
  1         2  
  1         9  
93 1     1   468 use namespace::clean;
  1         3  
  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           my $context = "Amazon::DynamoDB::$method";
328              
329 0 0 0       if (!ref $error) {
    0          
330 0 0         $error = 'UNDEFINED' if !defined $error;
331 0           croak "$context Unknown Error: $error";
332             }
333              
334             elsif (ref($error) eq 'HASH' and defined($error->{message})) {
335 0 0         if (defined($error->{type})) {
336 0           croak "$context: $error->{type}: $error->{message}";
337             }
338             else {
339 0           croak "$context: $error->{message}";
340             }
341             }
342              
343 0           require Data::Dumper;
344 0           croak "$context Unknown Error: " . Data::Dumper::Dumper( $error );
345             }
346              
347             =head2 set
348              
349             Set L<Starch::Store/set>.
350              
351             =head2 get
352              
353             Set L<Starch::Store/get>.
354              
355             =head2 remove
356              
357             Set L<Starch::Store/remove>.
358              
359             =cut
360              
361             sub set {
362             my ($self, $id, $namespace, $data, $expires) = @_;
363              
364             $expires += time() if $expires;
365              
366             my $serializer = $self->serializer();
367              
368             $data = {
369             map {
370             ref( $data->{$_} )
371             ? ($_ => '__SERIALIZED__:' . $serializer->serialize( $data->{$_} ))
372             : (
373             (!defined($data->{$_}))
374             ? ($_ => '__UNDEF__')
375             : (
376             ($data->{$_} eq '')
377             ? ($_ => '__EMPTY__')
378             : ($_ => $data->{$_})
379             )
380             )
381             }
382             keys( %$data )
383             };
384              
385             my $key = $self->stringify_key( $id, $namespace );
386              
387             my $f = $self->ddb->put_item(
388             TableName => $self->table(),
389             Item => {
390             $self->key_field() => $key,
391             $self->expiration_field() => $expires,
392             %$data,
393             },
394             );
395              
396             try { $f->get() }
397             catch { $self->_throw_ddb_error( 'put_item', $_ ) };
398              
399             return;
400             }
401              
402             sub get {
403 0     0 1   my ($self, $id, $namespace) = @_;
404              
405 0           my $key = $self->stringify_key( $id, $namespace );
406              
407 0           my $data;
408             my $f = $self->ddb->get_item(
409 0     0     sub{ $data = shift },
410 0 0         TableName => $self->table(),
411             Key => {
412             $self->key_field() => $key,
413             },
414             ConsistentRead => ($self->consistent_read() ? 'true' : 'false'),
415             );
416              
417 0     0     try { $f->get() }
418 0     0     catch { $self->_throw_ddb_error( 'get_item', $_ ) };
  0            
419              
420 0 0         return undef if !$data;
421              
422 0           my $expiration = delete $data->{ $self->expiration_field() };
423 0 0 0       if ($expiration and $expiration < time()) {
424 0           $self->remove( $id, $namespace );
425 0           return undef;
426             }
427              
428 0           delete $data->{ $self->key_field() };
429              
430 0           my $serializer = $self->serializer();
431              
432             return {
433             map {
434 0           ($data->{$_} =~ m{^__SERIALIZED__:(.*)$})
435             ? ($_ => $serializer->deserialize($1))
436             : (
437             ($data->{$_} eq '__UNDEF__')
438             ? ($_ => undef)
439             : (
440             ($data->{$_} eq '__EMPTY__')
441             ? ($_ => '')
442 0 0         : ($_ => $data->{$_})
    0          
    0          
443             )
444             )
445             }
446             keys( %$data )
447             };
448             }
449              
450             sub remove {
451 0     0 1   my ($self, $id, $namespace) = @_;
452              
453 0           my $key = $self->stringify_key( $id, $namespace );
454              
455 0           my $f = $self->ddb->delete_item(
456             TableName => $self->table(),
457             Key => {
458             $self->key_field() => $key,
459             },
460             );
461              
462 0     0     try { $f->get() }
463 0     0     catch { $self->_throw_ddb_error( 'delete_item', $_ ) };
  0            
464              
465 0           return;
466             }
467              
468             1;
469             __END__
470              
471             =head1 SUPPORT
472              
473             Please submit bugs and feature requests to the
474             Starch-Store-Amazon-DynamoDB GitHub issue tracker:
475              
476             L<https://github.com/bluefeet/Starch-Store-Amazon-DynamoDB/issues>
477              
478             =head1 AUTHOR
479              
480             Aran Clary Deltac <bluefeetE<64>gmail.com>
481              
482             =head1 ACKNOWLEDGEMENTS
483              
484             Thanks to L<ZipRecruiter|https://www.ziprecruiter.com/>
485             for encouraging their employees to contribute back to the open
486             source ecosystem. Without their dedication to quality software
487             development this distribution would not exist.
488              
489             =head1 LICENSE
490              
491             This library is free software; you can redistribute it and/or modify
492             it under the same terms as Perl itself.
493              
494             =cut
495