File Coverage

blib/lib/Algorithm/Classifier/IsolationForest/App/Command/streamc.pm
Criterion Covered Total %
statement 152 166 91.5
branch 72 98 73.4
condition 16 24 66.6
subroutine 17 19 89.4
pod 4 5 80.0
total 261 312 83.6


line stmt bran cond sub pod time code
1             package Algorithm::Classifier::IsolationForest::App::Command::streamc;
2              
3 81     81   56274 use strict;
  81         161  
  81         2654  
4 81     81   308 use warnings;
  81         131  
  81         3573  
5 81     81   353 use Algorithm::Classifier::IsolationForest::App -command;
  81         122  
  81         569  
6 81     81   23648 use File::Slurp qw(write_file);
  81         175  
  81         4340  
7 81     81   402 use File::Spec ();
  81         155  
  81         1725  
8 81     81   303 use Scalar::Util qw(looks_like_number);
  81         137  
  81         3226  
9 81     81   34847 use IO::Socket::UNIX ();
  81         914765  
  81         1702  
10 81     81   32481 use IO::Select ();
  81         106779  
  81         157260  
11              
12             # JSON::MaybeXS codec and the connected socket, set up in execute.
13             my $JSON;
14             my $SOCK;
15             my $TIMEOUT;
16             my $READ_BUF = '';
17              
18             sub opt_spec {
19             return (
20             [
21 23     23 1 599547 'set=s',
22             'Named streamd instance to talk to; the socket becomes .sock under the run dir, '
23             . 'exactly as streamd resolves it. Must match /\A[A-Za-z0-9+\-@_]+\z/.'
24             ],
25             [
26             'socket=s',
27             'Unix domain socket streamd listens on; default /var/run/iforest_streamd/streamd.sock. With '
28             . '--set this is instead the base run dir (default /var/run/iforest_streamd) holding .sock.',
29             { 'completion' => 'files' }
30             ],
31             [ 'timeout=i', 'Seconds to wait for each reply from the daemon.', { 'default' => 30 } ],
32              
33             # stream mode
34             [
35             'i=s',
36             'Input to stream through the daemon, one row per line; - reads stdin.',
37             { 'completion' => 'files' }
38             ],
39             [ 'o=s', 'Output the results to this file instead of printing.', { 'completion' => 'files' } ],
40             [ 'w', 'If the file specified via -o exists, over write it.' ],
41             [ 'd', 'Include the input data in the output (CSV input only).' ],
42             [
43             'mode=s',
44             "What each row does: 'prequential' (score against the model as it stood, then learn -- the "
45             . "default), 'learn' (learn only, no output), or 'score' (score only, nothing learned).",
46             { 'default' => 'prequential' }
47             ],
48             [
49             'jsonl',
50             'Input lines are JSON rows instead of CSV: an array is positional, an object is a tagged row '
51             . '(full munger plan, raw values may contain anything JSON can). Output is the daemon\'s '
52             . 'reply JSON lines verbatim, one per request (--batch 1 for one per row).'
53             ],
54             [
55             'batch=i',
56             'Rows per request message. Bigger amortises round trips; 1 gives per-row latency for '
57             . 'tail -F style pipelines.',
58             { 'default' => 256 }
59             ],
60              
61             # command mode
62             [ 'ping', 'Check the daemon is alive; exits 0 on pong.' ],
63             [ 'stats', 'Print the daemon stats (seen, window, threshold, connections, set, ...).' ],
64             [ 'save', 'Ask the daemon to save the model now; prints the file name.' ],
65             [ 'relearn-threshold', 'Ask the daemon to relearn the contamination decision threshold.' ],
66             [ 'json', 'Command mode: print the raw JSON reply instead of the text rendering.' ],
67             );
68             } ## end sub opt_spec
69              
70 0     0 1 0 sub abstract { 'Client for iforest streamd: stream rows through it or send it commands' }
71              
72             sub description {
73 0     0 1 0 'Talks to a running `iforest streamd` daemon over its Unix socket,
74             speaking the same one-JSON-document-per-line protocol.
75              
76             Stream mode (-i) feeds rows through the daemon and prints one result
77             per row, in order. Input is CSV by default (positional rows, matching
78             `iforest stream`; fields are sent as numbers when they look like
79             numbers and as raw strings otherwise, so munged columns pass through
80             untouched) and the output is `$score,$label` lines, with -d prepending
81             the input columns. With --jsonl each input line is instead a JSON row
82             -- an array for positional data, an object for a tagged row through
83             the full munger plan -- and the output is the daemon\'s reply JSON
84             lines verbatim. Rows are sent in --batch sized messages, lockstep;
85             each request is tagged with its starting input line number, so a bad
86             row dies naming the input line (rows earlier in that message were
87             already applied by the daemon -- prequential learning is not
88             transactional). All row validation is the daemon\'s: only it knows
89             whether the model munges.
90              
91             Command mode sends exactly one of --ping, --stats, --save, or
92             --relearn-threshold and renders the reply as text (--json for the raw
93             reply). The exit code is 0 on ok and non-zero on error, connect
94             failure, or timeout, so `iforest streamc --set web --ping` works
95             directly in health checks.
96              
97             --set/--socket resolve the socket path exactly as streamd does, so the
98             same flags reach the same daemon.
99             ';
100             } ## end sub description
101              
102             sub validate {
103 23     23 0 67 my ( $self, $opt, $args ) = @_;
104              
105 23 100 100     3877 if ( defined( $opt->{'set'} ) && $opt->{'set'} !~ /\A[A-Za-z0-9+\-@_]+\z/ ) {
106             $self->usage_error( '--set, "'
107 1         17 . $opt->{'set'}
108             . '", must match /\A[A-Za-z0-9+\-@_]+\z/ (letters, digits, and + - @ _ only)' );
109             }
110              
111 22         91 my @cmds = grep { $opt->{$_} } qw(ping stats save relearn_threshold);
  88         212  
112 22 100       121 if ( defined( $opt->{'i'} ) ) {
    100          
113 10 50       36 if ( scalar @cmds ) {
114 0         0 $self->usage_error('-i may not be combined with --ping/--stats/--save/--relearn-threshold');
115             }
116             } elsif ( scalar @cmds != 1 ) {
117 2         17 $self->usage_error(
118             'need either -i (stream mode) or exactly one of --ping, --stats, --save, --relearn-threshold');
119             }
120              
121 20 100 66     132 if ( defined( $opt->{'i'} ) && $opt->{'i'} ne '-' ) {
122 10 50       387 if ( !-f $opt->{'i'} ) {
    50          
123 0         0 $self->usage_error( '-i, "' . $opt->{'i'} . '", is not a file or does not exist' );
124             } elsif ( !-r $opt->{'i'} ) {
125 0         0 $self->usage_error( '-i, "' . $opt->{'i'} . '", is not readable' );
126             }
127             }
128              
129 20 50 66     125 if ( defined( $opt->{'o'} ) && !$opt->{'w'} && -e $opt->{'o'} ) {
      66        
130 0         0 $self->usage_error( '-o, "' . $opt->{'o'} . '", already exists and -w is not specified' );
131             }
132              
133 20 50       126 if ( $opt->{'mode'} !~ /\A(?:prequential|learn|score)\z/ ) {
134 0         0 $self->usage_error( '--mode, "' . $opt->{'mode'} . '", must be prequential, learn, or score' );
135             }
136              
137 20 100 100     70 if ( $opt->{'d'} && $opt->{'jsonl'} ) {
138 1         8 $self->usage_error('-d only applies to CSV input; --jsonl replies are already self-describing');
139             }
140              
141 19 50 66     99 if ( $opt->{'json'} && defined( $opt->{'i'} ) ) {
142 0         0 $self->usage_error('--json only applies to command mode');
143             }
144              
145 19 50       94 if ( $opt->{'batch'} < 1 ) {
146 0         0 $self->usage_error( '--batch, "' . $opt->{'batch'} . '", must be >= 1' );
147             }
148              
149 19 50       58 if ( $opt->{'timeout'} < 1 ) {
150 0         0 $self->usage_error( '--timeout, "' . $opt->{'timeout'} . '", must be >= 1' );
151             }
152              
153 19         72 return 1;
154             } ## end sub validate
155              
156             sub execute {
157 19     19 1 122 my ( $self, $opt, $args ) = @_;
158              
159             # Lazily required for the same reason streamd does it: App::Cmd loads
160             # every command module up front, and the rest of the CLI should work
161             # on a box without JSON::MaybeXS.
162 19 50       43 eval { require JSON::MaybeXS; 1 }
  19         7901  
  19         107291  
163             or die( 'iforest streamc requires JSON::MaybeXS for its wire protocol; install it: ' . $@ );
164 19         99 $JSON = JSON::MaybeXS->new( utf8 => 1, canonical => 1 );
165 19         403 $TIMEOUT = $opt->{'timeout'};
166              
167             # Resolve the socket exactly as streamd does (keep in sync with it):
168             # without a set the flag is the socket file; with one it is the base
169             # run dir holding .sock.
170 19         40 my $socket;
171 19 100       84 if ( defined $opt->{'set'} ) {
172 18 50       111 my $run = defined $opt->{'socket'} ? $opt->{'socket'} : '/var/run/iforest_streamd';
173 18         337 $socket = File::Spec->catfile( $run, $opt->{'set'} . '.sock' );
174             } else {
175 1 50       3 $socket = defined $opt->{'socket'} ? $opt->{'socket'} : '/var/run/iforest_streamd/streamd.sock';
176             }
177 19 50       98 die( '--socket, "'
178             . $socket
179             . '", is '
180             . length($socket)
181             . ' bytes; Unix socket paths are limited to ~104 bytes -- use a shorter path'
182             . "\n" )
183             if length($socket) > 100;
184              
185             $SOCK = IO::Socket::UNIX->new( Peer => $socket )
186             or die( 'failed to connect to "'
187             . $socket . '": '
188             . $!
189             . ' -- is streamd running'
190 19 50       185 . ( defined $opt->{'set'} ? ' with --set ' . $opt->{'set'} : '' ) . '?'
    100          
191             . "\n" );
192 18         5034 $SOCK->autoflush(1);
193              
194             # A daemon dropping us mid-write should surface as the read-side
195             # "no reply" error, not a silent SIGPIPE death.
196 18         913 local $SIG{PIPE} = 'IGNORE';
197              
198 18 100       132 return _command( $self, $opt ) if !defined $opt->{'i'};
199 9         45 return _stream( $self, $opt );
200             } ## end sub execute
201              
202             #-------------------------------------------------------------------------------
203             # wire helpers
204             #-------------------------------------------------------------------------------
205              
206             sub _request {
207 20     20   73 my ($msg) = @_;
208 20         42 print {$SOCK} $JSON->encode($msg) . "\n";
  20         860  
209 20         92 my $reply = _read_reply();
210 20 50       78 die( 'no reply from the daemon within ' . $TIMEOUT . 's (or it closed the connection)' . "\n" )
211             unless defined $reply;
212 20         62 return $reply;
213             }
214              
215             sub _read_reply {
216 20     20   57 my $deadline = time + $TIMEOUT;
217 20         165 my $sel = IO::Select->new($SOCK);
218 20         1378 while ( $READ_BUF !~ /\n/ ) {
219 20         53 my $left = $deadline - time;
220 20 50 33     140 return undef if $left <= 0 || !$sel->can_read($left);
221 20         200072 my $got = sysread( $SOCK, my $chunk, 65536 );
222 20 50       165 return undef unless $got;
223 20         179 $READ_BUF .= $chunk;
224             }
225 20         195 $READ_BUF =~ s/\A([^\n]*)\n//;
226 20         96 my $line = $1;
227 20         56 my $reply = eval { $JSON->decode($line) };
  20         334  
228 20 50       84 die( 'daemon sent an unparseable reply: ' . $@ ) if $@;
229 20         308 return { raw => $line, reply => $reply };
230             } ## end sub _read_reply
231              
232             #-------------------------------------------------------------------------------
233             # command mode
234             #-------------------------------------------------------------------------------
235              
236             sub _command {
237 9     9   23 my ( $self, $opt ) = @_;
238              
239 9         26 my ($which) = grep { $opt->{$_} } qw(ping stats save relearn_threshold);
  36         80  
240 9         28 ( my $cmd = $which ) =~ tr/_/-/;
241              
242 9         46 my $got = _request( { cmd => $cmd } );
243 9         36 my $reply = $got->{reply};
244 9 100       229 die( 'daemon error: ' . $reply->{error} . "\n" ) if defined $reply->{error};
245              
246 8 100       59 if ( $opt->{'json'} ) {
247 1         14 print $got->{raw} . "\n";
248 1         164 return 1;
249             }
250              
251 7         18 my $ok = $reply->{ok};
252 7 100       30 if ( ref $ok eq 'HASH' ) {
253 5         30 for my $k ( sort keys %$ok ) {
254 29 100       198 printf " %-20s %s\n", $k, ( defined $ok->{$k} ? $ok->{$k} : '(unset)' );
255             }
256             } else {
257 2 50       32 print( ( defined $ok ? $ok : 'ok' ) . "\n" );
258             }
259 7         1057 return 1;
260             } ## end sub _command
261              
262             #-------------------------------------------------------------------------------
263             # stream mode
264             #-------------------------------------------------------------------------------
265              
266             sub _stream {
267 9     9   28 my ( $self, $opt ) = @_;
268              
269 9         20 my $in_fh;
270 9 50       55 if ( $opt->{'i'} eq '-' ) {
271 0         0 $in_fh = \*STDIN;
272             } else {
273 9 50       408 open( $in_fh, '<', $opt->{'i'} ) or die( 'failed to open -i, "' . $opt->{'i'} . '": ' . $! . "\n" );
274             }
275              
276             # -o accumulates and writes atomically at the end (matching `iforest
277             # stream`), so it is unsuitable for an endless stdin; without it,
278             # results print as replies arrive.
279 9         32 my $results = '';
280             my $emit = sub {
281 27 100   27   64 if ( defined $opt->{'o'} ) { $results .= $_[0] . "\n" }
  5         12  
282 22         138 else { print $_[0] . "\n" }
283 9         56 };
284              
285 9         38 my $expected_cols;
286             my @rows; # decoded rows for the pending request
287 9         0 my @raw; # matching raw input lines, for -d
288 9         18 my $batch_start = 1; # input line number of $rows[0]
289 9         21 my $line_int = 0;
290              
291             my $flush = sub {
292 12 100   12   51 return unless @rows;
293 11         85 my $got = _request( { rows => [@rows], mode => $opt->{'mode'}, tag => $batch_start } );
294 11         60 my $reply = $got->{reply};
295 11 100       42 if ( defined $reply->{error} ) {
296 1         3 my $line = $batch_start;
297 1         3 my $err = $reply->{error};
298             # Batch errors come back as "row N: ..." with N relative to
299             # the message; map it back to the input line.
300 1 50       32 if ( $err =~ s/\Arow (\d+): // ) {
301 1         7 $line = $batch_start + $1;
302             }
303 1         329 die( 'line ' . $line . ' of input: ' . $err . "\n" );
304             } ## end if ( defined $reply->{error} )
305 10 100       75 if ( $opt->{'mode'} ne 'learn' ) {
306 9 100       32 if ( $opt->{'jsonl'} ) {
307 2         8 $emit->( $got->{raw} );
308             } else {
309 7         13 my $pairs = $reply->{scores};
310 7         32 for my $i ( 0 .. $#$pairs ) {
311 25 100       69 my $prefix = $opt->{'d'} ? $raw[$i] . ',' : '';
312 25         143 $emit->( $prefix . $pairs->[$i][0] . ',' . $pairs->[$i][1] );
313             }
314             }
315             } ## end if ( $opt->{'mode'} ne 'learn' )
316 10         42 @rows = ();
317 10         34 @raw = ();
318 10         75 $batch_start = $line_int + 1;
319 9         47 }; ## end $flush = sub
320              
321 9         235 while ( my $line = <$in_fh> ) {
322 92         131 $line_int++;
323 92         126 chomp $line;
324 92 50       239 if ( $line =~ /^\s*$/ ) {
325 0         0 $flush->(); # keep line-number accounting exact across blanks
326 0         0 $batch_start = $line_int + 1;
327 0         0 next;
328             }
329              
330 92 100       200 if ( $opt->{'jsonl'} ) {
331 4         14 my $row = eval { $JSON->decode($line) };
  4         62  
332 4 50       15 die( 'line ' . $line_int . ' of -i did not parse as JSON: ' . $@ ) if $@;
333 4 50 33     58 die( 'line ' . $line_int . ' of -i must be a JSON array (positional) or object (tagged)' . "\n" )
334             unless ref $row eq 'ARRAY' || ref $row eq 'HASH';
335 4         12 push @rows, $row;
336             } else {
337 88         172 my @fields = split( /,/, $line, -1 );
338 88 100       159 if ( !defined $expected_cols ) {
    100          
339 7         65 $expected_cols = scalar @fields;
340 7 50       37 die( 'Line ' . $line_int . ' of input has no columns' ) if $expected_cols < 1;
341             } elsif ( scalar @fields != $expected_cols ) {
342 1         188 die( 'Line '
343             . $line_int
344             . ' of input has '
345             . scalar(@fields)
346             . ' columns but expected '
347             . $expected_cols );
348             }
349              
350             # Numeric-looking fields travel as JSON numbers, everything
351             # else as strings for the daemon's munger plan to handle; the
352             # daemon owns validation either way.
353 87 50       120 push @rows, [ map { looks_like_number($_) ? 0 + $_ : $_ } @fields ];
  174         497  
354 87         171 push @raw, $line;
355             } ## end else [ if ( $opt->{'jsonl'} ) ]
356              
357 91 100       314 $flush->() if scalar @rows >= $opt->{'batch'};
358             } ## end while ( my $line = <$in_fh> )
359 8         40 $flush->();
360              
361 7 100       27 if ( defined $opt->{'o'} ) {
362 1         16 write_file( $opt->{'o'}, { 'atomic' => 1 }, $results );
363             }
364 7         2418 return 1;
365             } ## end sub _stream
366              
367             return 1;