File Coverage

blib/lib/Algorithm/Classifier/IsolationForest/App/Command/streamd.pm
Criterion Covered Total %
statement 300 379 79.1
branch 139 252 55.1
condition 25 80 31.2
subroutine 32 39 82.0
pod 4 5 80.0
total 500 755 66.2


line stmt bran cond sub pod time code
1             package Algorithm::Classifier::IsolationForest::App::Command::streamd;
2              
3 81     81   60789 use strict;
  81         203  
  81         2681  
4 81     81   320 use warnings;
  81         164  
  81         3308  
5 81     81   376 use Algorithm::Classifier::IsolationForest ();
  81         142  
  81         916  
6 81     81   308 use Algorithm::Classifier::IsolationForest::Online ();
  81         150  
  81         1582  
7 81     81   282 use Algorithm::Classifier::IsolationForest::App -command;
  81         146  
  81         760  
8 81     81   23704 use File::Slurp qw(read_file write_file);
  81         158  
  81         4624  
9 81     81   461 use File::Path qw(make_path);
  81         169  
  81         4136  
10 81     81   378 use File::Basename qw(dirname);
  81         184  
  81         3999  
11 81     81   376 use File::Spec ();
  81         147  
  81         1634  
12 81     81   283 use Scalar::Util qw(looks_like_number);
  81         139  
  81         2803  
13 81     81   373 use IO::Socket::UNIX ();
  81         161  
  81         1055  
14 81     81   254 use IO::Select ();
  81         658  
  81         1950  
15 81     81   333 use POSIX qw(setsid strftime);
  81         603  
  81         796  
16 81     81   5841 use Errno qw();
  81         162  
  81         4370  
17              
18             # The daemon is a singleton per process, so its runtime state lives in
19             # file-scoped lexicals rather than being threaded through every helper.
20             my $JSON; # JSON::MaybeXS codec (required at runtime, see execute)
21             my $OIF; # the online model
22             my %OPT; # resolved options
23             my $LOG_FH; # log handle (STDERR in foreground without --log)
24             my %CONN; # fileno => { sock, inbuf, outbuf, mode }
25             my $DIRTY = 0; # learned anything since the last save?
26             my $RUN; # cleared by SIGTERM/SIGINT
27             my $SAVE_NOW; # set by SIGUSR1
28             my $REOPEN_LOG; # set by SIGHUP
29              
30             # Sanity caps on per-connection buffers: a client is allowed big batch
31             # messages, but one that streams an endless line (or stops reading its
32             # replies) gets dropped instead of eating the daemon's memory.
33 81     81   333 use constant MAX_INBUF => 16 * 1024 * 1024;
  81         193  
  81         5438  
34 81     81   369 use constant MAX_OUTBUF => 16 * 1024 * 1024;
  81         149  
  81         376054  
35              
36             sub opt_spec {
37             return (
38             [
39 9     9 1 240727 'set=s',
40             'Named instance. Appended to --model-dir, and the socket/pid become .sock / .pid '
41             . 'under the run dir, so several daemons run side by side with no other flags. '
42             . 'Must match /\A[A-Za-z0-9+\-@_]+\z/.'
43             ],
44             [
45             'socket=s',
46             'Unix domain socket to listen on; default /var/run/iforest_streamd/streamd.sock. With --set '
47             . 'this is instead the base run dir (default /var/run/iforest_streamd) the .sock is created in.',
48             { 'completion' => 'files' }
49             ],
50             [
51             'pid=s',
52             'Where to write the daemon pid; default /var/run/iforest_streamd/streamd.pid. With --set '
53             . 'this is instead the base run dir (default /var/run/iforest_streamd) the .pid is created in.',
54             { 'completion' => 'files' }
55             ],
56             [
57             'model-dir=s',
58             'Directory timestamped model saves land in; the symlink latest.json in it always points '
59             . 'at the newest, and the daemon resumes from it at startup when it exists. With --set '
60             . 'the set name is appended as a subdirectory.',
61             { 'default' => '/var/db/iforest_streamd', 'completion' => 'files' }
62             ],
63             [
64             'save-interval=i',
65             'Seconds between periodic model saves (only when learning happened).',
66             { 'default' => 300 }
67             ],
68             [ 'keep=i', 'Prune all but the newest N timestamped model files after each save.' ],
69             [ 'f|foreground', 'Do not daemonize; log to stderr unless --log is given.' ],
70             [
71             'log=s',
72             'Log file. Defaults to /streamd.log when daemonized; stderr in the foreground.',
73             { 'completion' => 'files' }
74             ],
75             [ 'socket-mode=s', 'Octal permissions to chmod the socket file to (e.g. 0660).' ],
76             [ 'threshold=f', 'Alternative decision threshold to use for the label field. 0 < $val < 1' ],
77              
78             # creation knobs, used only when /latest.json does not exist yet
79             [ 'n=i', 'Number of isolation trees in the ensemble (new models only).' ],
80             [ 'window=i', 'Sliding window size; 0 disables forgetting (new models only).' ],
81             [ 'eta=i', 'max_leaf_samples: points a leaf accumulates before splitting (new models only).' ],
82             [ 'growth=s', "Leaf split-requirement growth, 'adaptive' or 'fixed' (new models only)." ],
83             [ 'subsample=f', 'Per-tree stream subsampling probability, in (0, 1] (new models only).' ],
84             [ 's=i', 'Seed int (new models only).' ],
85             [
86             'c=f',
87             'Contamination. Expected fraction of anomalies, in (0, 0.5]; the decision threshold is '
88             . 'relearned from the window before every save (new models only).'
89             ],
90             [
91             't=s@',
92             'Feature name tag. Pass once per feature; enables the tagged (JSON object) row form '
93             . '(new models only).'
94             ],
95             [
96             'mungers=s',
97             'JSON file of Algorithm::ToNumberMunger specs, keyed by feature tag (new models only; requires -t).',
98             { 'completion' => 'files' }
99             ],
100             [
101             'prototype=s',
102             'JSON prototype file to create the model from (new models only). May not be combined '
103             . 'with -t or --mungers. See PROTOTYPES in the module POD.',
104             { 'completion' => 'files' }
105             ],
106             );
107             } ## end sub opt_spec
108              
109 0     0 1 0 sub abstract { 'Run an Online Isolation Forest scoring daemon on a Unix socket, speaking JSON lines' }
110              
111             sub description {
112 0     0 1 0 'Runs a prequential scoring daemon around an Online Isolation Forest
113             model (Algorithm::Classifier::IsolationForest::Online): clients connect
114             to the Unix domain socket and exchange one JSON document per line.
115              
116             At startup the daemon resumes from /latest.json when it
117             exists; otherwise it creates a new model from the creation knobs (-n,
118             --window, --eta, --growth, --subsample, -s, -c, -t, --mungers,
119             --prototype -- the same set `iforest stream` takes). The model is saved
120             to a timestamped file in --model-dir every --save-interval seconds
121             (only when something was learned), on SIGUSR1, on the save command, and
122             at shutdown; the symlink latest.json is atomically repointed at every
123             save, so a restart resumes the stream losing at most one interval.
124              
125             Requests are JSON objects carrying exactly one of "row", "rows", or
126             "cmd", an optional "mode", and an optional "tag" (any JSON value,
127             echoed back verbatim in the reply -- a correlation tag, not to be
128             confused with feature tags):
129              
130             {"row": [0.1, 0.7]} -> {"score": 0.41, "label": 0}
131             {"row": {"cpu": 0.1, "mem": 0.7}} -> {"score": 0.41, "label": 0}
132             {"rows": [[...], {...}], "tag": "b7"} -> {"scores": [[0.41,0], ...], "tag": "b7"}
133             {"rows": [[...]], "mode": "learn"} -> {"ok": {"learned": 1}}
134             {"cmd": "mode", "mode": "score"} -> {"ok": {"mode": "score"}}
135             {"cmd": "ping"} -> {"ok": "pong"}
136             {"cmd": "stats"} -> {"ok": {"seen": ..., ...}}
137             {"cmd": "save"} -> {"ok": {"saved": "oiforest-....json"}}
138             {"cmd": "relearn-threshold"} -> {"ok": {"threshold": 0.61}}
139             anything invalid -> {"error": "...", "tag": ...}
140              
141             The array row form is positional (scalar mungers applied, like stream
142             CSV input); the object form is a tagged row and runs the full munger
143             plan, including expanding and combining mungers -- and, being JSON, the
144             raw values may safely contain commas, newlines, or any unicode.
145              
146             A worked tagged example. Create the daemon around raw HTTP request
147             data, with mungers turning the raw values into numbers (mungers.json
148             here; a --prototype carrying the same schema works identically):
149              
150             { "method": { "munger": "http_method_enum", "default": -1 },
151             "path_len": { "munger": "length", "from": "path" },
152             "host_entropy": { "munger": "entropy", "from": "host" } }
153              
154             iforest streamd --set web -t method -t path_len -t host_entropy \
155             --mungers mungers.json -c 0.05
156              
157             Clients then send the raw values themselves -- note the input fields
158             are the munger SOURCES (method, path, host), not the feature tags,
159             because the plan derives path_len and host_entropy from them:
160              
161             -> {"row": {"method": "GET", "path": "/index.html",
162             "host": "www.example.com"}, "tag": "r-1"}
163             <- {"score": 0.31, "label": 0, "tag": "r-1"}
164             -> {"row": {"method": "BREW", "path": "/aa,a\"a.php",
165             "host": "kq3xv9z2.biz"}, "tag": "r-2"}
166             <- {"score": 0.74, "label": 1, "tag": "r-2"}
167              
168             The same rows work from the shell via
169             `iforest streamc --set web --jsonl -i rows.jsonl`.
170              
171             Modes are prequential (score each row against the model as it stood, then
172             learn it -- the default), learn (learn only), and score (score only);
173             "mode" on a row/rows message overrides the connection default set by
174             the mode command for that message. A bad row gets an {"error": ...}
175             reply on that message only; the connection and the daemon live on (for
176             a "rows" batch, rows before the failing one were already processed).
177              
178             Multiple concurrent connections are supported; rows are applied to the
179             one shared model in the order their lines arrive, which defines the
180             stream order.
181              
182             --set NAME runs a named instance: the set name is appended to
183             --model-dir (so its saves, latest.json, and default log live under
184             their own subdirectory) and the socket/pid become .sock /
185             .pid under the run dir -- with --set, --socket and --pid name the
186             base run dir instead of the files. Several sets run side by side, each
187             with its own model, resume state, and double-start protection:
188              
189             iforest streamd --set web
190             iforest streamd --set dns --prototype dns-proto.json -c 0.02
191              
192             Set names must match /\A[A-Za-z0-9+\-@_]+\z/; since the class has no
193             "." or "/", a set name can only ever create one new path segment.
194              
195             Everything under --model-dir and the socket/pid directories is created
196             at startup when missing; when that fails (e.g. running unprivileged
197             with the /var defaults) the daemon dies immediately, before forking,
198             naming the directory and the flag to override.
199             ';
200             } ## end sub description
201              
202             sub validate {
203 9     9 0 26 my ( $self, $opt, $args ) = @_;
204              
205             # Anchored with \A/\z rather than ^/$ ($ tolerates a trailing newline).
206             # The class has no '.' or '/', so a set name can only ever create one
207             # new path segment -- no traversal is expressible.
208 9 100 100     1039 if ( defined( $opt->{'set'} ) && $opt->{'set'} !~ /\A[A-Za-z0-9+\-@_]+\z/ ) {
209             $self->usage_error( '--set, "'
210 3         34 . $opt->{'set'}
211             . '", must match /\A[A-Za-z0-9+\-@_]+\z/ (letters, digits, and + - @ _ only)' );
212             }
213              
214 6 50       35 if ( $opt->{'save_interval'} < 1 ) {
215 0         0 $self->usage_error( '--save-interval, "' . $opt->{'save_interval'} . '", must be >= 1 second' );
216             }
217              
218 6 50 33     31 if ( defined( $opt->{'keep'} ) && $opt->{'keep'} < 1 ) {
219 0         0 $self->usage_error( '--keep, "' . $opt->{'keep'} . '", must be >= 1' );
220             }
221              
222 6 0 0     21 if ( defined( $opt->{'threshold'} ) && ( $opt->{'threshold'} <= 0 || $opt->{'threshold'} >= 1 ) ) {
      33        
223 0         0 $self->usage_error( '--threshold, "' . $opt->{'threshold'} . '", needs to be greater than 0 and less than 1' );
224             }
225              
226 6 50 33     21 if ( defined( $opt->{'growth'} ) && $opt->{'growth'} !~ /\A(?:adaptive|fixed)\z/ ) {
227 0         0 $self->usage_error( '--growth, "' . $opt->{'growth'} . '", must be either adaptive or fixed' );
228             }
229              
230 6 50 33     20 if ( defined( $opt->{'socket_mode'} ) && $opt->{'socket_mode'} !~ /\A0?[0-7]{3}\z/ ) {
231 0         0 $self->usage_error( '--socket-mode, "' . $opt->{'socket_mode'} . '", must be octal like 0660' );
232             }
233              
234 6 50       19 if ( defined( $opt->{'mungers'} ) ) {
235 0 0       0 if ( !-f $opt->{'mungers'} ) {
    0          
    0          
236 0         0 $self->usage_error( '--mungers, "' . $opt->{'mungers'} . '", is not a file or does not exist' );
237             } elsif ( !-r $opt->{'mungers'} ) {
238 0         0 $self->usage_error( '--mungers, "' . $opt->{'mungers'} . '", is not readable' );
239             } elsif ( !defined( $opt->{'t'} ) ) {
240 0         0 $self->usage_error('--mungers requires feature tags (-t) to compile against');
241             }
242             }
243              
244 6 100       17 if ( defined( $opt->{'prototype'} ) ) {
245 1 50       35 if ( !-f $opt->{'prototype'} ) {
    50          
246 0         0 $self->usage_error( '--prototype, "' . $opt->{'prototype'} . '", is not a file or does not exist' );
247             } elsif ( !-r $opt->{'prototype'} ) {
248 0         0 $self->usage_error( '--prototype, "' . $opt->{'prototype'} . '", is not readable' );
249             }
250 1 50 33     7 if ( defined( $opt->{'t'} ) || defined( $opt->{'mungers'} ) ) {
251 0         0 $self->usage_error(
252             '--prototype may not be combined with -t or --mungers; the schema comes only from the prototype');
253             }
254             } ## end if ( defined( $opt->{'prototype'} ) )
255              
256 6         16 return 1;
257             } ## end sub validate
258              
259             sub execute {
260 6     6 1 34 my ( $self, $opt, $args ) = @_;
261              
262             # JSON::MaybeXS is required lazily so a box without it still has a
263             # working iforest CLI (App::Cmd loads every command module up front).
264 6 50       12 eval { require JSON::MaybeXS; 1 }
  6         2355  
  6         31743  
265             or die( 'iforest streamd requires JSON::MaybeXS for its wire protocol; install it: ' . $@ );
266 6         28 $JSON = JSON::MaybeXS->new( utf8 => 1, canonical => 1, allow_nonref => 0 );
267              
268 6         164 %OPT = %$opt;
269              
270             # --set turns --socket/--pid into base run dirs holding .sock /
271             # .pid and appends the set name to --model-dir, so several named
272             # daemons run side by side with no other flags. Without a set the
273             # flags are the socket/pid files themselves, defaulting as documented.
274 6 100       25 if ( defined $OPT{'set'} ) {
275 3 50       11 my $run = defined $OPT{'socket'} ? $OPT{'socket'} : '/var/run/iforest_streamd';
276 3         45 $OPT{'socket'} = File::Spec->catfile( $run, $OPT{'set'} . '.sock' );
277 3 50       13 my $prun = defined $OPT{'pid'} ? $OPT{'pid'} : '/var/run/iforest_streamd';
278 3         18 $OPT{'pid'} = File::Spec->catfile( $prun, $OPT{'set'} . '.pid' );
279 3         19 $OPT{'model_dir'} = File::Spec->catdir( $OPT{'model_dir'}, $OPT{'set'} );
280             } else {
281 3 50       11 $OPT{'socket'} = '/var/run/iforest_streamd/streamd.sock' unless defined $OPT{'socket'};
282 3 50       10 $OPT{'pid'} = '/var/run/iforest_streamd/streamd.pid' unless defined $OPT{'pid'};
283             }
284              
285             # Daemonizing chdirs to /, so every path used after that point must be
286             # absolute -- including the socket and pid file, which are unlinked at
287             # shutdown.
288 6         14 for my $path_opt (qw(socket pid model_dir log)) {
289             $OPT{$path_opt} = File::Spec->rel2abs( $OPT{$path_opt} )
290 24 100       202 if defined $OPT{$path_opt};
291             }
292              
293             # sun_path is 104 bytes on the BSDs and 108 on Linux (including the
294             # NUL); Socket.pm just warns and TRUNCATES an over-long path, which
295             # binds a socket nobody will ever find. Refuse loudly instead.
296             die( '--socket, "'
297             . $OPT{'socket'}
298             . '", is '
299             . length( $OPT{'socket'} )
300             . ' bytes; Unix socket paths are limited to ~104 bytes -- use a shorter path'
301             . "\n" )
302 6 50       24 if length( $OPT{'socket'} ) > 100;
303              
304             # --- directories, before anything forks or binds -----------------------
305 6         31 _ensure_dir( $OPT{'model_dir'}, '--model-dir' );
306 6         188 _ensure_dir( dirname( $OPT{'socket'} ), '--socket' );
307 6         118 _ensure_dir( dirname( $OPT{'pid'} ), '--pid' );
308              
309             # --- refuse to double-start ---------------------------------------------
310 6 50       69 if ( -e $OPT{'socket'} ) {
311 0         0 my $probe = IO::Socket::UNIX->new( Peer => $OPT{'socket'} );
312 0 0       0 die( 'another daemon is already listening on "' . $OPT{'socket'} . '"' . "\n" ) if $probe;
313 0         0 unlink $OPT{'socket'}; # stale socket from an unclean exit
314             }
315 6 50       138 if ( -f $OPT{'pid'} ) {
316 0         0 my $old = read_file( $OPT{'pid'} );
317 0 0       0 chomp $old if defined $old;
318 0 0 0     0 if ( defined $old && $old =~ /\A\d+\z/ && ( kill( 0, $old ) || $!{EPERM} ) ) {
      0        
      0        
319 0         0 die( 'another daemon appears to be running (pid ' . $old . ' from "' . $OPT{'pid'} . '")' . "\n" );
320             }
321 0         0 unlink $OPT{'pid'}; # stale pid file
322             }
323              
324             # --- resume or create the model ----------------------------------------
325 6         79 my $latest = File::Spec->catfile( $OPT{'model_dir'}, 'latest.json' );
326 6 100       147 if ( -e $latest ) {
    100          
327 1         11 $OIF = Algorithm::Classifier::IsolationForest->load($latest);
328 1 50       4 die( '"' . $latest . '" is not an online model; streamd only works on those' . "\n" )
329             unless ref $OIF eq 'Algorithm::Classifier::IsolationForest::Online';
330             } elsif ( defined $OPT{'prototype'} ) {
331 1         2 my $proto = eval {
332 1         8 Algorithm::Classifier::IsolationForest->validate_prototype( scalar read_file( $OPT{'prototype'} ) );
333             };
334 1 50       8 die( '--prototype, "' . $OPT{'prototype'} . '", is not a valid prototype: ' . $@ ) if $@;
335             die( '--prototype, "' . $OPT{'prototype'} . '", is for a batch model; streamd needs an online one' . "\n" )
336 1 50       5 unless $proto->{class} eq 'online';
337              
338 1         2 my %overrides;
339 1 50       3 $overrides{'n_trees'} = $OPT{'n'} if defined $OPT{'n'};
340 1 50       3 $overrides{'window_size'} = $OPT{'window'} if defined $OPT{'window'};
341 1 50       2 $overrides{'max_leaf_samples'} = $OPT{'eta'} if defined $OPT{'eta'};
342 1 50       3 $overrides{'growth'} = $OPT{'growth'} if defined $OPT{'growth'};
343 1 50       3 $overrides{'subsample'} = $OPT{'subsample'} if defined $OPT{'subsample'};
344 1 50       4 $overrides{'seed'} = $OPT{'s'} if defined $OPT{'s'};
345 1 50       14 $overrides{'contamination'} = $OPT{'c'} if defined $OPT{'c'};
346              
347 1         2 $OIF = eval { Algorithm::Classifier::IsolationForest->new_from_prototype( $proto, %overrides ) };
  1         7  
348 1 50       8 die( '--prototype, "' . $OPT{'prototype'} . '", failed to create a model: ' . $@ ) if $@;
349             } else {
350 4         9 my $mungers;
351 4 50       13 if ( defined $OPT{'mungers'} ) {
352 0         0 $mungers = eval { $JSON->decode( scalar read_file( $OPT{'mungers'} ) ) };
  0         0  
353 0 0       0 die( '--mungers, "' . $OPT{'mungers'} . '", did not parse as JSON: ' . $@ ) if $@;
354 0 0       0 die( '--mungers, "' . $OPT{'mungers'} . '", must be a JSON object of tag => spec' )
355             unless ref $mungers eq 'HASH';
356             }
357             $OIF = Algorithm::Classifier::IsolationForest::Online->new(
358             'n_trees' => $OPT{'n'},
359             'window_size' => $OPT{'window'},
360             'max_leaf_samples' => $OPT{'eta'},
361             'growth' => $OPT{'growth'},
362             'subsample' => $OPT{'subsample'},
363             'seed' => $OPT{'s'},
364             'contamination' => $OPT{'c'},
365 4         64 'feature_names' => $OPT{'t'},
366             'mungers' => $mungers,
367             );
368             } ## end else [ if ( -e $latest ) ]
369              
370             # --- bind, then daemonize (the listening fd survives the forks) --------
371             my $listener = IO::Socket::UNIX->new(
372             Local => $OPT{'socket'},
373             Listen => 64,
374 6 50       71 ) or die( 'failed to listen on "' . $OPT{'socket'} . '": ' . $! . "\n" );
375 6         2228 $listener->blocking(0);
376 6 50       137 if ( defined $OPT{'socket_mode'} ) {
377             chmod( oct( $OPT{'socket_mode'} ), $OPT{'socket'} )
378 0 0       0 or die( 'failed to chmod "' . $OPT{'socket'} . '" to ' . $OPT{'socket_mode'} . ': ' . $! . "\n" );
379             }
380              
381 6 50       48 if ( !$OPT{'f'} ) {
382             $OPT{'log'} = File::Spec->catfile( $OPT{'model_dir'}, 'streamd.log' )
383 0 0       0 unless defined $OPT{'log'};
384 0         0 _daemonize();
385             }
386 6         29 _open_log();
387              
388 6         84 write_file( $OPT{'pid'}, { 'atomic' => 1 }, $$ . "\n" );
389              
390             # --- signals -------------------------------------------------------------
391 6         4036 $RUN = 1;
392 6         38 $SAVE_NOW = 0;
393 6         13 $REOPEN_LOG = 0;
394 6     6   126 local $SIG{TERM} = sub { $RUN = 0 };
  6         2048313  
395 6     0   65 local $SIG{INT} = sub { $RUN = 0 };
  0         0  
396 6     0   74 local $SIG{USR1} = sub { $SAVE_NOW = 1 };
  0         0  
397 6     0   51 local $SIG{HUP} = sub { $REOPEN_LOG = 1 };
  0         0  
398 6         80 local $SIG{PIPE} = 'IGNORE';
399              
400             _log( 'listening on '
401             . $OPT{'socket'}
402             . ( -e $latest ? ' (resumed ' : ' (new model, ' )
403             . ( defined $OPT{'set'} ? 'set=' . $OPT{'set'} . ', ' : '' ) . 'seen='
404             . $OIF->seen
405             . ', save-interval='
406             . $OPT{'save_interval'} . 's'
407             . ', model-dir='
408 6 100       135 . $OPT{'model_dir'}
    100          
409             . ')' );
410              
411             # --- event loop ----------------------------------------------------------
412 6         65 my $rsel = IO::Select->new($listener);
413 6         414 my $wsel = IO::Select->new();
414 6         74 my $next_save = time + $OPT{'save_interval'};
415              
416 6         23 while ($RUN) {
417 108         263 my $timeout = $next_save - time;
418 108 50       285 $timeout = 0 if $timeout < 0;
419              
420 108         361 my @ready = $rsel->can_read($timeout);
421 108         63183274 for my $s (@ready) {
422 103 100       458 if ( $s == $listener ) {
423 24         375 while ( my $cl = $listener->accept ) {
424 24         5755 $cl->blocking(0);
425 24         688 $CONN{ fileno($cl) } = { sock => $cl, inbuf => '', outbuf => '', mode => 'prequential' };
426 24         170 $rsel->add($cl);
427             }
428 24         4037 next;
429             }
430 79         391 _read_from( $s, $rsel, $wsel );
431             } ## end for my $s (@ready)
432              
433             # Drain clients whose replies did not fit in one write.
434 108 50       413 if ( $wsel->count ) {
435 0         0 for my $s ( $wsel->can_write(0) ) {
436 0         0 _flush( $s, $rsel, $wsel );
437             }
438             }
439              
440 108 50       730 if ($REOPEN_LOG) {
441 0         0 $REOPEN_LOG = 0;
442 0         0 _open_log();
443 0         0 _log('log reopened on SIGHUP');
444             }
445 108 100 66     894 if ( $SAVE_NOW || time >= $next_save ) {
446 4 50 66     52 _save_model( $SAVE_NOW ? 'signal' : 'interval' ) if $DIRTY || $SAVE_NOW;
    100          
447 4         10 $SAVE_NOW = 0;
448 4         28 $next_save = time + $OPT{'save_interval'};
449             }
450             } ## end while ($RUN)
451              
452             # --- shutdown ------------------------------------------------------------
453 6         41 _log('shutting down');
454 6 100       125 _save_model('shutdown') if $DIRTY;
455 6         28 for my $c ( values %CONN ) {
456 1         23 close $c->{sock};
457             }
458 6         22 %CONN = ();
459 6         128 close $listener;
460 6         669 unlink $OPT{'socket'};
461 6         431 unlink $OPT{'pid'};
462 6         34 _log('bye');
463              
464 6         1710 return 1;
465             } ## end sub execute
466              
467             #-------------------------------------------------------------------------------
468             # startup helpers
469             #-------------------------------------------------------------------------------
470              
471             sub _ensure_dir {
472 18     18   59 my ( $dir, $flag ) = @_;
473 18 100       394 if ( !-d $dir ) {
474 5         13 my $err;
475 5         1448 make_path( $dir, { mode => oct('0755'), error => \$err } );
476 5 50       79 die( 'could not create "'
477             . $dir
478             . '" (needed for '
479             . $flag
480             . '); create it, fix permissions, or point '
481             . $flag
482             . ' somewhere writable'
483             . "\n" )
484             if !-d $dir;
485             } ## end if ( !-d $dir )
486 18 50       133 die( '"' . $dir . '" (needed for ' . $flag . ') is not writable; fix permissions or override ' . $flag . "\n" )
487             unless -w $dir;
488 18         33 return 1;
489             } ## end sub _ensure_dir
490              
491             # Classic double-fork daemonization. The parents leave via POSIX::_exit
492             # so no END blocks (Inline's, App::Cmd's) run twice.
493             sub _daemonize {
494 0 0   0   0 defined( my $pid = fork() ) or die( 'fork failed: ' . $! . "\n" );
495 0 0       0 POSIX::_exit(0) if $pid;
496 0 0       0 setsid() or die( 'setsid failed: ' . $! . "\n" );
497 0 0       0 defined( $pid = fork() ) or die( 'second fork failed: ' . $! . "\n" );
498 0 0       0 POSIX::_exit(0) if $pid;
499 0 0       0 chdir '/' or die( 'chdir / failed: ' . $! . "\n" );
500 0 0       0 open( STDIN, '<', '/dev/null' ) or die( 'reopen STDIN failed: ' . $! . "\n" );
501 0         0 return 1;
502             } ## end sub _daemonize
503              
504             sub _open_log {
505 6 100   6   23 if ( defined $OPT{'log'} ) {
506 3 50       129 open( my $fh, '>>', $OPT{'log'} ) or die( 'failed to open log "' . $OPT{'log'} . '": ' . $! . "\n" );
507 3         25 $fh->autoflush(1);
508 3         99 $LOG_FH = $fh;
509 3 50       11 if ( !$OPT{'f'} ) {
510 0 0       0 open( STDOUT, '>>', $OPT{'log'} ) or die( 'reopen STDOUT failed: ' . $! . "\n" );
511 0 0       0 open( STDERR, '>>', $OPT{'log'} ) or die( 'reopen STDERR failed: ' . $! . "\n" );
512 0         0 STDOUT->autoflush(1);
513 0         0 STDERR->autoflush(1);
514             }
515             } else {
516 3         16 $LOG_FH = \*STDERR;
517             }
518 6         11 return 1;
519             } ## end sub _open_log
520              
521             sub _log {
522 24     24   75 my ($msg) = @_;
523 24         42 print {$LOG_FH} strftime( '%Y-%m-%dT%H:%M:%S', localtime ) . ' [' . $$ . '] ' . $msg . "\n";
  24         2034  
524 24         111 return 1;
525             }
526              
527             #-------------------------------------------------------------------------------
528             # model persistence
529             #-------------------------------------------------------------------------------
530              
531             # Timestamped save + atomic symlink flip. Returns the file name saved
532             # to (relative to model-dir, which is also what the symlink stores so
533             # the directory stays relocatable).
534             sub _save_model {
535 6     6   25 my ($why) = @_;
536              
537             # Keep the persisted default cutoff tracking the stream, like the
538             # stream command does before its save.
539 6 50 33     55 if ( defined $OIF->{contamination} && $OIF->window_count ) {
540 0         0 $OIF->relearn_threshold;
541             }
542              
543 6         371 my $base = 'oiforest-' . strftime( '%Y%m%d-%H%M%S', localtime );
544 6         26 my $name = $base . '.json';
545 6         15 my $n = 0;
546 6         603 while ( -e File::Spec->catfile( $OPT{'model_dir'}, $name ) ) {
547 0         0 $n++;
548 0         0 $name = $base . '-' . $n . '.json';
549             }
550 6         136 write_file( File::Spec->catfile( $OPT{'model_dir'}, $name ), { 'atomic' => 1 }, $OIF->to_json );
551              
552 6         101039 my $tmp = File::Spec->catfile( $OPT{'model_dir'}, '.latest.tmp.' . $$ );
553 6         269 unlink $tmp;
554 6 50       496 symlink( $name, $tmp )
555             or _log( 'WARNING: symlink for latest.json failed: ' . $! );
556 6 50       590 rename( $tmp, File::Spec->catfile( $OPT{'model_dir'}, 'latest.json' ) )
557             or _log( 'WARNING: renaming latest.json symlink failed: ' . $! );
558              
559 6         32 $DIRTY = 0;
560 6         53 _log( 'saved ' . $name . ' (' . $why . ', seen=' . $OIF->seen . ')' );
561 6 50       39 _prune_models() if defined $OPT{'keep'};
562 6         23 return $name;
563             } ## end sub _save_model
564              
565             sub _prune_models {
566 0 0   0   0 opendir( my $dh, $OPT{'model_dir'} ) or return;
567 0         0 my @models = sort { ( stat($a) )[9] <=> ( stat($b) )[9] }
568 0         0 map { File::Spec->catfile( $OPT{'model_dir'}, $_ ) }
569 0         0 grep { /\Aoiforest-.*\.json\z/ } readdir($dh);
  0         0  
570 0         0 closedir $dh;
571 0         0 while ( scalar @models > $OPT{'keep'} ) {
572 0         0 my $old = shift @models;
573 0 0       0 unlink $old and _log( 'pruned ' . $old );
574             }
575 0         0 return 1;
576             } ## end sub _prune_models
577              
578             #-------------------------------------------------------------------------------
579             # connection handling
580             #-------------------------------------------------------------------------------
581              
582             sub _drop {
583 23     23   65 my ( $s, $rsel, $wsel ) = @_;
584 23         220 $rsel->remove($s);
585 23         1488 $wsel->remove($s);
586 23         727 delete $CONN{ fileno($s) };
587 23         772 close $s;
588 23         216 return 1;
589             }
590              
591             sub _read_from {
592 79     79   216 my ( $s, $rsel, $wsel ) = @_;
593 79 50       460 my $c = $CONN{ fileno($s) } or return;
594              
595 79         1578 my $got = sysread( $s, my $chunk, 65536 );
596 79 50       341 if ( !defined $got ) {
597 0 0 0     0 return if $!{EAGAIN} || $!{EWOULDBLOCK} || $!{EINTR};
      0        
598 0         0 return _drop( $s, $rsel, $wsel );
599             }
600 79 100       241 return _drop( $s, $rsel, $wsel ) if $got == 0; # client closed
601              
602 56         245 $c->{inbuf} .= $chunk;
603 56 50 33     201 if ( length( $c->{inbuf} ) > MAX_INBUF && $c->{inbuf} !~ /\n/ ) {
604 0         0 _log( 'dropping client: unterminated line exceeded ' . MAX_INBUF . ' bytes' );
605 0         0 return _drop( $s, $rsel, $wsel );
606             }
607              
608 56         522 while ( $c->{inbuf} =~ s/\A([^\n]*)\n// ) {
609 56         233 my $line = $1;
610 56 50       237 next if $line =~ /\A\s*\z/;
611 56         236 _handle_line( $c, $line );
612 56 50       373 return _drop( $s, $rsel, $wsel ) if length( $c->{outbuf} ) > MAX_OUTBUF;
613             }
614 56 50       249 _flush( $s, $rsel, $wsel ) if length $c->{outbuf};
615 56         153 return 1;
616             } ## end sub _read_from
617              
618             sub _flush {
619 56     56   106 my ( $s, $rsel, $wsel ) = @_;
620 56 50       234 my $c = $CONN{ fileno($s) } or return;
621 56         124 while ( length $c->{outbuf} ) {
622 56         1636 my $wrote = syswrite( $s, $c->{outbuf} );
623 56 50       200 if ( !defined $wrote ) {
624 0 0 0     0 if ( $!{EAGAIN} || $!{EWOULDBLOCK} || $!{EINTR} ) {
      0        
625 0 0       0 $wsel->add($s) unless $wsel->exists($s);
626 0         0 return;
627             }
628 0         0 return _drop( $s, $rsel, $wsel );
629             }
630 56         215 substr( $c->{outbuf}, 0, $wrote, '' );
631             } ## end while ( length $c->{outbuf} )
632 56 50       262 $wsel->remove($s) if $wsel->exists($s);
633 56         1217 return 1;
634             } ## end sub _flush
635              
636             #-------------------------------------------------------------------------------
637             # protocol
638             #-------------------------------------------------------------------------------
639              
640             # One request line -> one reply line, appended to the connection's
641             # output buffer. Any croak from the model becomes an {"error": ...}
642             # reply on this message alone; the connection and daemon live on.
643             sub _handle_line {
644 56     56   158 my ( $c, $line ) = @_;
645              
646 56         110 my $msg = eval { $JSON->decode($line) };
  56         1072  
647 56 100 66     316 if ( $@ || ref $msg ne 'HASH' ) {
648 1         10 ( my $err = $@ ) =~ s/ at \S+ line \d+\.?\s*\z//s;
649 1   50     9 return _reply( $c, { error => 'request is not a JSON object: ' . ( $err || 'wrong type' ) } );
650             }
651              
652 55 100       229 my @tag = exists $msg->{tag} ? ( tag => $msg->{tag} ) : ();
653              
654 55         206 my @kinds = grep { exists $msg->{$_} } qw(row rows cmd);
  165         429  
655 55 100       140 if ( scalar @kinds != 1 ) {
656 1         6 return _reply( $c, { error => q{request needs exactly one of "row", "rows", or "cmd"}, @tag } );
657             }
658 54         127 my $kind = $kinds[0];
659              
660 54 100       202 my $mode = exists $msg->{mode} ? $msg->{mode} : $c->{mode};
661 54 100 33     499 if ( !defined $mode || ref $mode || $mode !~ /\A(?:prequential|learn|score)\z/ ) {
      66        
662 1         5 return _reply( $c, { error => q{mode must be "prequential", "learn", or "score"}, @tag } );
663             }
664              
665 53 100       155 if ( $kind eq 'cmd' ) {
666 26         86 return _handle_cmd( $c, $msg, \@tag );
667             }
668              
669 27 100       105 my $rows = $kind eq 'row' ? [ $msg->{row} ] : $msg->{rows};
670 27 50 33     125 if ( ref $rows ne 'ARRAY' || !@$rows ) {
671 0         0 return _reply( $c, { error => q{"rows" must be a non-empty JSON array of rows}, @tag } );
672             }
673              
674 27         122 my @scored;
675 27         44 my $i = 0;
676 27         172 for my $row (@$rows) {
677 257         317 my $score = eval { _apply_row( $row, $mode ) };
  257         421  
678 257 100       457 if ($@) {
679 4         29 ( my $err = $@ ) =~ s/ at \S+ line \d+\.?\s*\z//s;
680 4         10 chomp $err;
681 4 100       15 my $where = $kind eq 'row' ? '' : 'row ' . $i . ': ';
682 4         22 return _reply( $c, { error => $where . $err, @tag } );
683             }
684 253 100       467 push @scored, $score if defined $score;
685 253         379 $i++;
686             } ## end for my $row (@$rows)
687              
688 23 100       74 if ( $mode eq 'learn' ) {
689 6         49 return _reply( $c, { ok => { learned => scalar @$rows }, @tag } );
690             }
691              
692 17         51 my $threshold = _threshold();
693 17 50       48 my @pairs = map { [ 0 + $_, ( $_ >= $threshold ? 1 : 0 ) ] } @scored;
  36         112  
694 17 100       43 if ( $kind eq 'row' ) {
695 7         37 return _reply( $c, { score => $pairs[0][0], label => $pairs[0][1], @tag } );
696             }
697 10         77 return _reply( $c, { scores => \@pairs, @tag } );
698             } ## end sub _handle_line
699              
700             sub _handle_cmd {
701 26     26   141 my ( $c, $msg, $tag ) = @_;
702 26         58 my $cmd = $msg->{cmd};
703 26 50 33     198 $cmd = '' if !defined $cmd || ref $cmd;
704              
705 26 100       67 if ( $cmd eq 'ping' ) {
706 8         54 return _reply( $c, { ok => 'pong', @$tag } );
707             }
708 18 100       76 if ( $cmd eq 'mode' ) {
709 1         4 my $mode = $msg->{mode};
710 1 50 33     12 if ( !defined $mode || ref $mode || $mode !~ /\A(?:prequential|learn|score)\z/ ) {
      33        
711 0         0 return _reply( $c, { error => q{mode must be "prequential", "learn", or "score"}, @$tag } );
712             }
713 1         2 $c->{mode} = $mode;
714 1         22 return _reply( $c, { ok => { mode => $mode }, @$tag } );
715             }
716 17 100       163 if ( $cmd eq 'stats' ) {
717             return _reply(
718             $c,
719             {
720             ok => {
721             seen => 0 + $OIF->seen,
722             window => 0 + $OIF->window_count,
723             n_features => ( defined $OIF->{n_features} ? 0 + $OIF->{n_features} : undef ),
724             threshold => 0 + _threshold(),
725             connections => 0 + scalar( keys %CONN ),
726             dirty => ( $DIRTY ? 1 : 0 ),
727 12 100       139 set => $OPT{'set'},
    100          
728             },
729             @$tag
730             }
731             );
732             } ## end if ( $cmd eq 'stats' )
733 5 100       31 if ( $cmd eq 'save' ) {
734 3         10 my $name = eval { _save_model('command') };
  3         21  
735 3 50       14 if ($@) {
736 0         0 ( my $err = $@ ) =~ s/ at \S+ line \d+\.?\s*\z//s;
737 0         0 return _reply( $c, { error => $err, @$tag } );
738             }
739 3         25 return _reply( $c, { ok => { saved => $name }, @$tag } );
740             }
741 2 100       7 if ( $cmd eq 'relearn-threshold' ) {
742 1         2 my $ok = eval { $OIF->relearn_threshold; 1 };
  1         9  
  0         0  
743 1 50       5 if ( !$ok ) {
744 1         10 ( my $err = $@ ) =~ s/ at \S+ line \d+\.?\s*\z//s;
745 1         3 chomp $err;
746 1         6 return _reply( $c, { error => $err, @$tag } );
747             }
748 0         0 return _reply( $c, { ok => { threshold => 0 + $OIF->decision_threshold }, @$tag } );
749             }
750 1         6 return _reply( $c, { error => 'unknown cmd "' . $cmd . '"', @$tag } );
751             } ## end sub _handle_cmd
752              
753             sub _reply {
754 56     56   126 my ( $c, $reply ) = @_;
755 56         753 $c->{outbuf} .= $JSON->encode($reply) . "\n";
756 56         426 return 1;
757             }
758              
759             # The effective label cutoff, resolved per message so relearns and the
760             # contamination refresh at save time take effect immediately.
761             sub _threshold {
762             return
763 29 50   29   165 defined $OPT{'threshold'} ? $OPT{'threshold'}
    50          
764             : defined $OIF->decision_threshold ? $OIF->decision_threshold
765             : 0.5;
766             }
767              
768             # One row through the model. A JSON object is a tagged row (full munger
769             # plan, expanding/combining mungers included); a JSON array is positional
770             # (scalar mungers, like stream CSV input). Either way the final vector
771             # is validated numeric before it touches the model -- JSON delivers
772             # typed values, so anything non-numeric left after munging is a caller
773             # bug worth an explicit error rather than Perl's silent string-to-0.
774             # Returns the score, or undef in learn mode. Croaks on any problem.
775             sub _apply_row {
776 257     257   381 my ( $row, $mode ) = @_;
777              
778 257         314 my $vec;
779 257 100       631 if ( ref $row eq 'HASH' ) {
    50          
780 67         149 $vec = $OIF->tagged_row_to_array( $row, 'streamd' );
781             } elsif ( ref $row eq 'ARRAY' ) {
782 190         264 $vec = $row;
783 190 50 33     704 if ( ref $OIF->{mungers} eq 'HASH' && %{ $OIF->{mungers} } ) {
  0         0  
784 0         0 $vec = $OIF->munge_rows( [$row] )->[0];
785             }
786             } else {
787 0         0 die 'row must be a JSON array (positional) or object (tagged)' . "\n";
788             }
789              
790 255         501 for my $col ( 0 .. $#$vec ) {
791 511 50       857 next if !defined $vec->[$col]; # undef defers to the model's missing policy
792 511 100       1100 die 'column ' . ( $col + 1 ) . ' is not a number after munging' . "\n"
793             unless looks_like_number( $vec->[$col] );
794             }
795              
796 254 100       488 if ( $mode eq 'learn' ) {
797 216         509 $OIF->learn( [$vec] );
798 216         271 $DIRTY = 1;
799 216         375 return undef;
800             }
801 38 100       96 if ( $mode eq 'score' ) {
802 28         146 return $OIF->score_samples( [$vec] )->[0];
803             }
804 10         45 my $score = $OIF->score_learn( [$vec] )->[0];
805 9         38 $DIRTY = 1;
806 9         24 return $score;
807             } ## end sub _apply_row
808              
809             return 1;