Showing posts with label awk. Show all posts
Showing posts with label awk. Show all posts

Thursday, December 23, 2010

Quick and Dirty Reporting with Awk

A few years ago, I wrote about starting to use awk after a long time. Over the last couple of years, I've used awk on and off, although not very frequently, and never beyond the basic pattern described in that post.

Last week, I described an AspectJ aspect that wrote out timing information into the server logs. I initially thought of using OpenOffice Calc, but then stumbled upon Jadu Saikia's post on implementing group-by with awk, and I realized that awk could do the job just as easily, and would be easier to use (for multiple invocations).

Here is the script I came up with.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#!/usr/bin/env awk -f
# Builds a report out of aggregate elapsed time data similar to the Spring 
# StopWatch prettyPrint report.
# Usage:
# my_profile_report.awk [-v qt=query_term] filename
#
BEGIN {
  FS="|";
}
(qt == "" || qt == $3) {
  counts[$4]++;
  times[$4] += $5;
  if (mins[$4] + 0 != 0) {
    if (mins[$4] > $5) {
      mins[$4] = $5;
    }
  } else {
    mins[$4] = $5;
  }
  if (maxs[$4] + 0 != 0) {
    if (maxs[$4] < $5) {
      maxs[$4] = $5;
    }
  } else {
    maxs[$4] = $5;
  }
  totals += $5;
}
END {
  totavg = 0;
  for (t in times) {
    totavg += times[t]/counts[t];
  }
  printf("---------------------------------------------------------------------\n");
  printf("%-32s %8s %8s %8s %8s\n", "Controller", "Avg(ms)", "%", "Min(ms)", "Max(ms)");
  printf("---------------------------------------------------------------------\n");
  for (t in times) {
    avg = times[t]/counts[t];
    perc = avg * 100 / totavg;
    printf("%-32s %8.2f %8.2f %8.2f %8.2f\n", t, avg, perc, mins[t], maxs[t]);
  }
  printf("---------------------------------------------------------------------\n");
  printf("%-32s %8.2f\n", "Average Elapsed (ms):", totavg);
  printf("---------------------------------------------------------------------\n");
}

As shown in my previous post, the input file looks something like this (without the header, I just grep the server log file with the aspect name).

1
2
3
4
5
6
# aspect_name|request_uuid|query|tile_name|elapsed_time_millis
MyAspect2|ed9ce777-263e-4fe5-a8af-4ea84d78add3|some query|TileAController|132
MyAspect2|ed9ce777-263e-4fe5-a8af-4ea84d78add3|some query|TileBController|49
MyAspect2|ed9ce777-263e-4fe5-a8af-4ea84d78add3|some query|TileCController|2
MyAspect2|ed9ce777-263e-4fe5-a8af-4ea84d78add3|some query|TileDController|3
...

The script can be invoked with or without a qt parameter. Without parameters, the script computes the average, minimum and maximum elapsed times across all the queries that were given to the application during the profiling run. The report looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
sujit@cyclone:aspects$ ./my_profile_report.awk input_file
---------------------------------------------------------------------
Controller                        Avg(ms)        %  Min(ms)  Max(ms)
---------------------------------------------------------------------
TileFController                      1.42     0.02     0.00    42.00
TileAController                    187.67     2.83    27.00   685.00
TileJController                    169.97     2.56     7.00  3140.00
TileEController                      9.14     0.14     2.00    44.00
TileNController                     45.91     0.69     4.00   234.00
TileIController                    444.91     6.71     0.00  3427.00
TileDController                   1506.30    22.72   792.00 12140.00
TileMController                    184.88     2.79     0.00  3078.00
TileHController                     13.67     0.21     7.00    50.00
TileCController                     34.06     0.51    14.00   108.00
TileLController                    759.73    11.46     3.00  9921.00
TileGController                   2473.24    37.31   579.00 10119.00
TileBController                     24.48     0.37     7.00   132.00
TileKController                    773.97    11.67     0.00  8554.00
---------------------------------------------------------------------
Average Elapsed (ms):             6629.35
---------------------------------------------------------------------

If we want only the times for a specific query term, then we can specify it on the command line as shown below. If the query has been invoked multiple times, then the report will show the average of the calls for the query. In this case, there is only a single invocation for the query, so the minimum and maximum times are redundant.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
sujit@cyclone:aspects$ ./my_profile_report.awk -v qt=some_query input_file
---------------------------------------------------------------------
Controller                        Avg(ms)        %  Min(ms)  Max(ms)
---------------------------------------------------------------------
TileFController                      0.00     0.00     0.00     0.00
TileAController                    106.00     4.57   106.00   106.00
TileJController                      7.00     0.30     7.00     7.00
TileEController                     10.00     0.43    10.00    10.00
TileNController                      8.00     0.34     8.00     8.00
TileIController                      0.00     0.00     0.00     0.00
TileDController                   1309.00    56.42  1309.00  1309.00
TileMController                      0.00     0.00     0.00     0.00
TileHController                     12.00     0.52    12.00    12.00
TileCController                     25.00     1.08    25.00    25.00
TileLController                     64.00     2.76    64.00    64.00
TileGController                    767.00    33.06   767.00   767.00
TileBController                     12.00     0.52    12.00    12.00
TileKController                      0.00     0.00     0.00     0.00
---------------------------------------------------------------------
Average Elapsed (ms):             2320.00
---------------------------------------------------------------------

The script uses awk's associative arrays and the post processing block. Before this script, I had read about awk's BEGIN/END blocks but didn't know why I would want to use them, and I didn't know about awk's associative arrays at all. Thanks to Daniel Robbin's three part series - Common threads: Awk by example, Part 1, 2 and 3, I now have a greater understanding and appreciation of awk.

Monday, August 18, 2008

Running Lucli in Batch mode

Lucli is an interactive command line tool that provides functionality similar to Luke, ie the ability to look inside a Lucene index. Sometimes, such as when the indexes are large and sitting on a remote machine, and when you don't need the full power of Luke to query it, it is often more convenient to use Lucli for examining the index, than copying it over to your local machine and use Luke to get at it. Of course, there are other ways to use Luke, such as X-forwarding or using VNC which requires setup on the server side, which may not be feasible in all cases.

Yet another possible good use for Lucli is to have it be called by shell scripts. I mean, if an index is worth spending effort to examine manually, its probably worth scripting this work so it can happen without human intervention. However, because it is an interactive tool, it is not possible with the version (2.4 at the time of this writing) that is available in the Lucene SVN repository. This post describes what I had to do to build this functionality into Lucli, and to add a new custom method that is (perhaps) unique to us.

Adding the --file option

I added a -f (or --file GNU style) option that is recognized by Lucli on the command line, and if so, it creates a new ConsoleReader object that reads from the file name specified after the option. The script File object is retrieved in the parseArgs() method, shown below.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
    private void parseArgs(String[] args) {
      String errorMessage = null;
        if (args.length > 0) {
          if (args.length == 2 &&
              ("--file".equals(args[0]) || "-f".equals(args[0]))) {
            File scriptfile = new File(args[1]);
            if (scriptfile.exists() && scriptfile.canExecute()) {
              this.script = scriptfile;
              return;
            } else {
              errorMessage = "File:" + args[1] + " does not exist or is not executable";
            }
          }
          usage(errorMessage);
          System.exit(1);
        }
    }

And the Lucli constructor would use a null check on the script File object to determine if it should open a no-args ConsoleReader, or a special one that reads from the script file. Like this:

1
2
3
4
5
6
7
        ConsoleReader cr = null;
        if (script != null) {
          cr = new ConsoleReader(new FileInputStream(script), new PrintWriter(System.out));
        } else {
          cr = new ConsoleReader();
        }
        ...

I also updated the usage() method to report that --file filename is an optional but supported parameter.

1
2
3
4
5
6
    private void usage(String errorMessage) {
        message("Usage: lucli.Lucli [--file script_file]");
        if (errorMessage != null) {
          message("(" + errorMessage + ")");
        }
    }

Finally, I modified the call to the lucli.Lucli in the shell script to accept command line parameters.

1
2
3
#!/bin/bash
...
$JAVA_HOME/bin/java -Xmx${LUCLI_MEMORY} -cp $CLASSPATH lucli.Lucli $*

Usage: example script to find #-records

To find the number of records in an index, I would run Lucli interactively from the command line as follows:

1
2
3
4
5
6
7
8
9
sujit@sirocco:~$ ./run.sh 
Lucene CLI. Using directory 'index'. Type 'help' for instructions.
lucli> index /path/to/my/index
Lucene CLI. Using directory '/path/to/my/index'. Type 'help' for instructions.
Index has 6626 documents 
All Fields:[...]
Indexed Fields:[...]
lucli> quit
sujit@sirocco:~$ 

So to run this in batch mode, we create a file (call it /tmp/script1.lucli) like so:

1
2
index /path/to/my/index
quit

And then, to get the number of records, we run the following command line script. Obviously, this command can now be put into another script that does something with the number of records.

1
2
3
4
sujit@sirocco:~$ ./run.sh --file /tmp/script1.lucli | grep "Index has" |\
  gawk '{print $3}'
6626
sujit@sirocco:~$

New method list([fieldname1;fieldname2;...])

Another question that I often have to answer is if a particular record is in the index or not, or what new records are available in a freshly built index. Because it is so easy to write this sort of ad-hoc stuff, I have a Python and a Java version that I use interchangeably, depending on whether I have PyLucene set up on the target environment or not. However, this seemed to be a good time to standardize on one tool, so I added a "list" method to Lucli. You can see it here:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
sujit@sirocco:~$ ./run.sh 
lucli> help
 count: Return the number of hits for a search. Example: count foo
 explain: Explanation that describes how the document scored against query. 
          Example: explain foo
 help: Display help about commands
 index: Choose a different lucene index. Example index my_index
 info: Display info about the current Lucene index. Example: info
 list: Lists value of field list (field1;field2;...) or all fields for all 
          records in the selected index
 optimize: Optimize the current index
 quit: Quit/exit the program
 search: Search the current index. Example: search foo
 terms: Show the first 100 terms in this index. Supply a field name to only 
          show terms in a specific field. Example: terms
 tokens: Does a search and shows the top 10 tokens for each document. Verbose! 
          Example: tokens foo
lucli> 

For this, I had to add a new method list() in LuceneMethods and add code to Lucli to trigger this method if it encounters a list call. This consists of an addCommand("list", LIST, ...) call and a case LIST in the switch in the Lucli.handleCommand() method. The case statement is shown below:

1
2
3
4
5
6
                        case LIST:
                          for (int ii = 1; ii < words.length; ii++) {
                            query += words[ii] + ";";
                          }
                          luceneMethods.list(query);
                          break;

And here is the contents of the list() method in the LuceneMethods class. As you can see, its fairly straightforward. If field names are given, then it just returns the field values and if no field names are given, then it returns all fields. The filtering is done in Unix. This is OK to do here, since these are really ad-hoc usages and so are unlikely to impact performance.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
  /** Lists out named fields from the index (all records)
   * @throws IOException
   */
  public void list(String query) throws IOException {
    String[] fieldNames = null;
    if ("".equals(query.trim())) {
      getFieldInfo();
      fieldNames = new String[fields.size()];
      for (int i = 0; i < fieldNames.length; i++) {
        fieldNames[i] = (String) fields.get(i);
      }
    } else {
      fieldNames = query.split(";");
    }
    IndexReader indexReader = IndexReader.open(indexName);
    int maxDoc = indexReader.maxDoc();
    for (int i = 0; i < maxDoc; i++) {
      Document doc = indexReader.document(i);
      StringBuffer buf = new StringBuffer();
      for (int j = 0; j < fieldNames.length; j++) {
        if (j > 0) {
          buf.append(";");
        }
        buf.append(doc.get(fieldNames[j]));
      }
      message(buf.toString());
    }
    indexReader.close();
  }

Usage: example script to find records with specific URL pattern

As mentioned before, we do our pattern matching stuff using Unix tools. This keeps the Lucli method simple and more generic. The example use case is for finding the records (identified by title) that satisfy a particular URL pattern. As before, we can experiment in the interactive shell, and build our script file (script2.lucli) like so:

1
2
3
index /path/to/my/index
list title;url
quit

And call it like so:

1
2
sujit@sirocco:~$ ./run.sh --file /tmp/script2.lucli | \
  gawk -F';' --source '{if ($2 ~ /my_url_pattern/) printf("%s %s\n", $1, $2)}'

The full patch file

You can just patch the stock Lucli with the output of "svn diff" from the src/java/lucli subdirectory. I had to do some hacks (listed below) to get the Lucli to compile, which won't be captured in an "svn diff" output, so I am not publishing the diff of the entire module.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
Index: LuceneMethods.java
===================================================================
--- LuceneMethods.java (revision 683771)
+++ LuceneMethods.java (working copy)
@@ -352,6 +352,36 @@
     indexReader.close();
   }
 
+  /** Lists out named fields from the index (all records)
+   * @throws IOException
+   */
+  public void list(String query) throws IOException {
+    String[] fieldNames = null;
+    if ("".equals(query.trim())) {
+      getFieldInfo();
+      fieldNames = new String[fields.size()];
+      for (int i = 0; i < fieldNames.length; i++) {
+        fieldNames[i] = (String) fields.get(i);
+      }
+    } else {
+      fieldNames = query.split(";");
+    }
+    IndexReader indexReader = IndexReader.open(indexName);
+    int maxDoc = indexReader.maxDoc();
+    for (int i = 0; i < maxDoc; i++) {
+      Document doc = indexReader.document(i);
+      StringBuffer buf = new StringBuffer();
+      for (int j = 0; j < fieldNames.length; j++) {
+        if (j > 0) {
+          buf.append(";");
+        }
+        buf.append(doc.get(fieldNames[j]));
+      }
+      message(buf.toString());
+    }
+    indexReader.close();
+  }
+  
   /** Sort Hashtable values
    * @param h the hashtable we're sorting
    * from http://developer.java.sun.com/developer/qow/archive/170/index.jsp
Index: Lucli.java
===================================================================
--- Lucli.java (revision 683771)
+++ Lucli.java (working copy)
@@ -55,7 +55,9 @@
  */
 
 import java.io.File;
+import java.io.FileInputStream;
 import java.io.IOException;
+import java.io.PrintWriter;
 import java.io.UnsupportedEncodingException;
 import java.util.Iterator;
 import java.util.Set;
@@ -95,11 +97,13 @@
  final static int INDEX = 7;
  final static int TOKENS = 8;
  final static int EXPLAIN = 9;
+ final static int LIST = 10;
 
  String historyFile;
  TreeMap commandMap = new TreeMap();
  LuceneMethods luceneMethods; //current cli class we're using
  boolean enableReadline; //false: use plain java. True: shared library readline
+ File script = null;
 
  /**
   Main entry point. The first argument can be a filename with an
@@ -124,11 +128,17 @@
   addCommand("index", INDEX, "Choose a different lucene index. Example index my_index", 1);
   addCommand("tokens", TOKENS, "Does a search and shows the top 10 tokens for each document. Verbose! Example: tokens foo", 1);
   addCommand("explain", EXPLAIN, "Explanation that describes how the document scored against query. Example: explain foo", 1);
-
+  addCommand("list", LIST, "Lists value of field list (field1;field2;...) or all fields for all records in the selected index");
+  
   //parse command line arguments
   parseArgs(args);
 
-  ConsoleReader cr = new ConsoleReader();
+  ConsoleReader cr = null;
+  if (script != null) {
+    cr = new ConsoleReader(new FileInputStream(script), new PrintWriter(System.out));
+  } else {
+    cr = new ConsoleReader();
+  }
   //Readline.readHistoryFile(fullPath);
   cr.setHistory(new History(new File(historyFile)));
   
@@ -234,6 +244,12 @@
     }
     luceneMethods.search(query, true, false, cr);
     break;
+   case LIST:
+     for (int ii = 1; ii < words.length; ii++) {
+       query += words[ii] + ";";
+     }
+     luceneMethods.list(query);
+     break;
    case HELP:
     help();
     break;
@@ -315,18 +331,31 @@
  }
 
  /*
-  * Parse command line arguments (currently none)
+  * Only parse command line argument --file (or -f).
   */
  private void parseArgs(String[] args) {
+   String errorMessage = null;
   if (args.length > 0) {
-   usage();
+    if (args.length == 2 && 
+        ("--file".equals(args[0]) || "-f".equals(args[0]))) {
+      File scriptfile = new File(args[1]);
+      if (scriptfile.exists() && scriptfile.canExecute()) {
+        this.script = scriptfile;
+        return;
+      } else {
+        errorMessage = "File:" + args[1] + " does not exist or is not executable";
+      }
+    }
+   usage(errorMessage);
    System.exit(1);
   }
  }
 
- private void usage() {
-  message("Usage: lucli.Lucli");
-  message("(currently, no parameters are supported)");
+ private void usage(String errorMessage) {
+  message("Usage: lucli.Lucli [--file script_file]");
+  if (errorMessage != null) {
+    message("(" + errorMessage + ")");
+  }
  }
 
  private class Command {

To get Lucli to compile locally, I had to make the following changes to build.xml.

  • Change the reference to ../contrib-build.xml to contrib-build.xml and copy the contrib-build.xml from svn to my project root directory.
  • Change the reference to ../common-build.xml in contrib-build.xml and copy the common-build.xml from svn to my project root directory.
  • Change the location of lucene.jar to a ${project.root}/lib and copy over an existing lucene jar there, to suppress the target "build-lucene" from firing and giving errors.

Conclusion

As you can see, extending Lucli is quite easy. There are just two classes and the code is quite easy to read. Because it does not have too much functionality, when faced with the task of modifying it to suit your own needs, it is very easy (perhaps easier) to just go ahead write your own little subset. The reason I extended Lucli rather than do that are:

  • this gives me all the other cool stuff that Lucli already has without my writing code for it,
  • my changes may potentially benefit a larger number of people, and
  • what goes around comes around, and one day I will benefit from somebody else's extension to Lucli. To be fair, I have already benefited a lot from the code and information contributions by others over the years.

Saturday, November 10, 2007

Rediscovering awk

My first job after college was with HCL Limited (now HCL Infosystems), the #1 Unix-based minicomputer manufacturer (at least at that time) in India. Unix was not as ubiquitous as it is today, at least in India, but it was making aggressive inroads into shops dominated by proprietary mainframe and midrange OSes. At that point I knew nothing about Unix - our college computers had DEC VAX/VMS and MS-DOS installed, and I had done some work with ICL's mainframes and a proprietary minicomputer OS called BEST as a summer intern.

The first thing HCL did was to ship the new recruits to a 2 week boot camp at their training school at Dehra Doon, a little mountain city in North India. There, after about a 2 day introduction to Unix and common commands, we were broken up into groups of 2 and each group handed an approximately 80-100 page paper manual and 24 hours to prepare a presentation for our classmates on a common Unix command. Ours was awk. It wasn't the hardest, considering that two other groups got lex and yacc, but it wasn't the easiest thing to do either, considering our experience with Unix so far, and the fact that we had a green screen monitor with csh for command history. Needless to say, the presentation did not go too well.

Over the years, I had used various Unix commands like sed, grep, cut, paste, tr, etc, hooked them up into shell scripts, worked with scripting languages like Perl and Python, but I have always steered clear of awk. Probably because I could get my work done without using awk, or maybe it was some sort of subconscious fear thing. Anyway, never used awk since then, that is, until a few weeks ago.

I had this Java program which was crawling a set of pages, and pulling down the title, summary and URL for each page into a pipe-delimited records into a flat file. So the Java program would dump a file that looked like this. The top two lines are not part of the output, they are to aid in describing the file format for this blog.

1
2
3
4
5
6
7
8
# output.txt
# TITLE|URL|SUMMARY
r1c1|r1c2|r1c3
r2c1|r2c2|r2c3
r3c1|r3c2|r3c3
r4c1|r4c2|r4c3
r5c1|r5c2|r5c3
...

I would then use sed scripts to replace the pipe characters appropriately, so my output file would look something like this:

1
2
3
4
# output.txt
# TITLE|URL|SUMMARY
insert into sometable(title,url,summary)values('r1c1','r1c2','r1c3');
...

And then use this file as an input SQL script to load all the information into a database table. Some time later, I found a bug in the summary generation algorithm, so I needed to update the summaries in the database. So the SQL to be generated would be like this:

1
2
3
4
# output.txt
# TITLE|URL|SUMMARY
update sometable set summary='r1c2' where title='r1c1' and url='r1c2';
...

I could simply change the Java code to rewrite the columns appropriately, but this seemed to be an almost textbook application of awk, so I bit. So my awk script to reorder the columns looked like this:

1
2
3
sujit@sirocco:/tmp$ gawk -F"|" \
  --source '{printf("%s|%s|%s\n",$3,$1,$2)}' \
  output.txt > output1.txt

And then apply another sed script to replace the pipes with the appropriate text to create the update SQL call.

A few days later, I had a another situation where I was given a file which had been originally generated from one of my programs, uploaded to a database table, but later annotated by a human being. Specifically, certain items had been marked as 'delete' and I knew that I could use the URL as a unique key for the delete SQL. So the annotated file looked like this:

1
2
3
4
5
6
7
8
# output1.txt
# TITLE|URL|SUMMARY|ANNOTATION
r1c1|r1c2|r1c3|keep
r2c1|r2c2|r2c3|delete
r3c1|r3c2|r3c3|keep
r4c1|r4c2|r4c3|keep
r5c1|r5c2|r5c3|delete
...

Flushed with my recent success with awk, I decided to give it a go again. This time, all I needed was the rows annotated with "delete", and the URL to use as the key. So my new awk script looked like:

1
2
3
sujit@sirocco:/tmp$ gawk -F"|" \
  --source '{if ($4=="delete") printf("%s\n",$2)}' \
  output1.txt > output2.txt

It turns out that awk (or gawk, its GNU cousin that I am using) is actually pretty much a full fledged programming language, and has quite a comprehensive set of built in string processing functions. One of my colleagues remarked that he had seen entire web sites written with awk. I don't know if I would want to do that, but from what I see, it is a very powerful tool for writing compact string transformations on the command line. I am definitely going to use this more in future, replacing use-cases where I used a combination of cut, paste and sed to do command line text file processing.

Even after so many years, the awk man pages still appear a bit dense to me. However, there is a lot of free information available on awk on the Internet. I used Greg Goebel's awk tutorial, An Awk Primer, which I found to be very useful. There is also the Awk reference page on the Unix-Manuals site, which can come in useful if you already know how to write scripts in awk and need a function reference.