Line 0
Link Here
|
|
|
1 |
|
2 |
use warnings; |
3 |
|
4 |
=head1 NAME |
5 |
|
6 |
Koha::Util::PureFunctions |
7 |
|
8 |
=head1 SYNOPSIS |
9 |
|
10 |
package X; |
11 |
use base 'Koha::Util::PureFunctions'; |
12 |
|
13 |
sub GetFoo : PureFunction { |
14 |
... |
15 |
IsValid(...); |
16 |
} |
17 |
|
18 |
sub IsValid : PureFunction { |
19 |
... |
20 |
} |
21 |
|
22 |
sub UpdateFoo { |
23 |
my $foo = GetFoo(); # it's ok, pure functions can be called inside impure functions |
24 |
} |
25 |
|
26 |
|
27 |
sub MakeBar : ImpureFunction { |
28 |
} |
29 |
|
30 |
sub ListBars : PureFunction { |
31 |
my @bars = ...; |
32 |
@bars or push @bars, MakeBar(); # ERROR, you can't call an impure function inside a pure one |
33 |
} |
34 |
|
35 |
=head1 DESCRIPTION |
36 |
|
37 |
Pure functions are functions that returns the same results given the same arguments. |
38 |
|
39 |
In some cases, and for a strict scope, functions that read data from the database can be seen as pure functions. |
40 |
E.g. when showing items or members in a long list in a function that DOES NOT change the data on the database. |
41 |
|
42 |
There are several cases in Koha where there is a loop over objects that need to be decorated, which will end up calling |
43 |
the same getter for the same entity over and over again. (e.g.: C4::Members::GetMember, C4::Circulation::GetBranchItemRule or Koha::Objects::find) |
44 |
|
45 |
By marking the function (or method) with the PureFunction attribute, you declare that the result of that function SHOULD be reused, provided the caller |
46 |
is pure. |
47 |
|
48 |
=head2 Storage |
49 |
|
50 |
every time a pure function is called, it checks if it is inside a "pure" environment already. |
51 |
|
52 |
If not, a new environment is created and used to share a "cache" of all the calls. |
53 |
|
54 |
If it is, then the "cache" is used to verify if the same requests has been returned in the same "pure" environment. |
55 |
|
56 |
Note: it is similar to "scope", but it extends to the callees |
57 |
|
58 |
When the first function exits, the whole "pure" environment is destroyed. |
59 |
|
60 |
=head2 Pure/Impure |
61 |
|
62 |
It is possible to call pure functions from an impure (or untagged) function. but and error will be thrown (at runtime) when a Impure function is called inside a PureFunction |
63 |
|
64 |
=head2 Memoize |
65 |
|
66 |
It works similarly to the cpan module Memoize, but instead of holding the cached results forever (or an expiry time) it only lives for the "scope" of the pure environment, granting safe results. |
67 |
|
68 |
=head2 How it works |
69 |
|
70 |
at BEGIN time, all the methods that have one of the pure attributes are replaced by a wrapped function, which performs the caching and statistics, and optionally call the original function. |
71 |
|
72 |
there is a lot of magic happening there, which relies on Attribute::Handler, local variables, and function redef. |
73 |
|
74 |
=head1 FUNCTIONS |
75 |
|
76 |
=head2 pure |
77 |
|
78 |
Koha::Util::PureFunctions::pure { |
79 |
... |
80 |
}; |
81 |
|
82 |
Utility function that create a "pure" environment if not present. Useful when you want only a part of a function/script to be pure. |
83 |
|
84 |
=head1 BUGS AND ISSUE |
85 |
|
86 |
This module requires to be extended (use base/parent) by subclasses. |
87 |
|
88 |
This can be problematic with Exporter, where subroutines MUST be exported AFTER the module wrap them |
89 |
|
90 |
=head1 AUTHOR |
91 |
|
92 |
Francesco Rivetti E<lt>oha@oha.itE<gt> |
93 |
|
94 |
=cut |
95 |
|
96 |
package Koha::Util::PureFunctions; |
97 |
our $DBG; |
98 |
our $STORE; # the temporary storage (cache) |
99 |
our @STACK; # the stack of pure only functions |
100 |
our $HOOKS; |
101 |
|
102 |
# this will keep this function private, since lots of objects will inherit |
103 |
my $fire_hooks = sub { |
104 |
while (my ($k,$v) = each %$HOOKS) { |
105 |
eval { |
106 |
$v->(@_); |
107 |
1; |
108 |
} or warn "error while executing hook '$k': $@"; |
109 |
} |
110 |
}; |
111 |
|
112 |
use Attribute::Handlers; |
113 |
use JSON; our $JSON = JSON->new()->utf8(1)->canonical(1)->allow_blessed(1); |
114 |
use Data::Dumper; |
115 |
use Time::HiRes qw/time/; |
116 |
|
117 |
sub pure(&) { |
118 |
my ($code) = @_; |
119 |
local $STORE = $STORE//{}; |
120 |
my @caller = caller(1); |
121 |
local @STACK = (@STACK, "$caller[1]:$caller[2]"); |
122 |
$code->(); |
123 |
} |
124 |
|
125 |
|
126 |
sub ImpureFunction :ATTR(BEGIN) { |
127 |
my ($package, $subp, $code, $attr) = @_; |
128 |
|
129 |
# get the full name of the function |
130 |
my $subn = $package."::".*{$subp}{NAME}; |
131 |
|
132 |
# prepare the replacement function |
133 |
my $memo = sub { |
134 |
# are we in a Pure environment? |
135 |
$STORE and die "function '$subn' is marked as Impure, but called inside '".$STACK[-1]."'"; |
136 |
|
137 |
# call and return the original function |
138 |
$code->(); |
139 |
}; |
140 |
$DBG and print STDERR "redefining '$subn'...\n"; |
141 |
|
142 |
# replace the function |
143 |
no warnings 'redefine'; |
144 |
*{$subp} = $memo; |
145 |
} |
146 |
|
147 |
sub PureFunction :ATTR(BEGIN) { |
148 |
my ($package, $subp, $code, $attr) = @_; |
149 |
|
150 |
# get the full name of the function |
151 |
my $name = *{$subp}{NAME}; |
152 |
my $subn = $package."::".$name; |
153 |
|
154 |
# prepare the replacement |
155 |
my $memo = sub { |
156 |
# push this function in the pure function stack |
157 |
local @STACK = (@STACK, $subn); |
158 |
|
159 |
# initiate a pure environment if not present already |
160 |
my $old_store = $STORE; |
161 |
local $STORE = $STORE//{}; |
162 |
|
163 |
# compute a key with the argument list |
164 |
my $key = $JSON->encode(\@_); |
165 |
$key = "@".$key if wantarray; # different caches based on wantarray |
166 |
|
167 |
$DBG and printf STDERR "$subn : PureFunction(%s)... store: $::STORE\n", $key; |
168 |
|
169 |
# find or create the cache for this specific function |
170 |
my $cache = $STORE->{$subn}//={}; |
171 |
|
172 |
my $stats = { |
173 |
package => $package, |
174 |
name => $name, |
175 |
}; |
176 |
$stats->{new} = 1 unless $old_store; |
177 |
|
178 |
if (exists $cache->{$key}) { |
179 |
# cache HIT |
180 |
$stats->{hit}++; |
181 |
} else { |
182 |
# cache MISS |
183 |
$stats->{miss}++; |
184 |
|
185 |
# measure time |
186 |
my $t0 = time(); |
187 |
|
188 |
if (wantarray) { |
189 |
$cache->{$key} = [$code->(@_)]; # exceptions will fall thru, no caching for them |
190 |
} else { |
191 |
$cache->{$key} = $code->(@_); |
192 |
} |
193 |
my $dt = time() - $t0; |
194 |
$stats->{miss_time} += $dt; |
195 |
if ($dt>3) { |
196 |
warn "$subn() took $dt seconds\nargs: $key..." |
197 |
} |
198 |
} |
199 |
$fire_hooks->(%$stats); # unroll the hash, so it can't be modified |
200 |
|
201 |
# return the results |
202 |
if (wantarray) { |
203 |
return @{$cache->{$key}}; |
204 |
} else { |
205 |
return $cache->{$key}; |
206 |
} |
207 |
}; |
208 |
|
209 |
# replace the function |
210 |
$DBG and print STDERR "redefining '$subn'...\n"; |
211 |
no warnings 'redefine'; |
212 |
*{$subp} = $memo; |
213 |
} |
214 |
|
215 |
# TEST METHOD, works like PureFunction but don't enable an environment and don't do any caching |
216 |
# usefull to check the effects of a pure function without applying it |
217 |
sub TrackedFunction :ATTR(BEGIN) { |
218 |
my ($package, $subp, $code, $attr) = @_; |
219 |
my $name = *{$subp}{NAME}."?"; |
220 |
my $subn = $package."::".*{$subp}{NAME}."?"; |
221 |
my $memo = sub { |
222 |
#local @STACK = (@STACK, $subn); |
223 |
my $key = $JSON->encode(\@_); |
224 |
$key = "A$key" if wantarray; |
225 |
$DBG and printf STDERR "$subn : TrackedFunction(%s)... store: $::STORE\n", $key; |
226 |
my $cache = $STORE ? $STORE->{$subn}//{} : {}; |
227 |
my $stats = { |
228 |
package => $package, |
229 |
name => $name, |
230 |
}; |
231 |
if (exists $cache->{$key}) { |
232 |
$stats && $stats->{hit}++; |
233 |
# fetch anyways |
234 |
my $t0 = time(); |
235 |
if (wantarray) { |
236 |
$cache->{$key} = [$code->(@_)]; # exceptions will fall thru, no caching for them |
237 |
} else { |
238 |
$cache->{$key} = $code->(@_); |
239 |
} |
240 |
my $dt = time() - $t0; |
241 |
$stats->{saved_time} += $dt; |
242 |
} else { |
243 |
$stats && $stats->{miss}++; |
244 |
my $t0 = time(); |
245 |
if (wantarray) { |
246 |
$cache->{$key} = [$code->(@_)]; # exceptions will fall thru, no caching for them |
247 |
} else { |
248 |
$cache->{$key} = $code->(@_); |
249 |
} |
250 |
my $dt = time() - $t0; |
251 |
$stats->{miss_time} += $dt; |
252 |
} |
253 |
$fire_hooks->(%$stats); |
254 |
if (wantarray) { |
255 |
return @{$cache->{$key}}; |
256 |
} else { |
257 |
return $cache->{$key}; |
258 |
} |
259 |
}; |
260 |
$DBG and print STDERR "redefining '$subn'...\n"; |
261 |
no warnings 'redefine'; |
262 |
*{$subp} = $memo; |
263 |
} |
264 |
|
265 |
1; |
266 |
|