Showing posts with label nutch. Show all posts
Showing posts with label nutch. Show all posts

Saturday, March 03, 2012

Distributed Solr: Indexing and Searching

This post is not about SolrCloud. SolrCloud is going to be available in the upcoming Solr 4.x release, and renders a lot of the work described in this blog post obsolete. However, I am working with the latest released Solr version (3.5), and I needed to have a way to have Nutch index its contents onto a bank of Solr server shards, which I could then use to run distributed queries against.

Indexing

Distributed indexing can be achieved quite simply with Nutch by making some fairly minor changes to the SolrWriter and SolrIndexerReducer (in the NutchGora branch, I haven't looked at the trunk, so can't comment).

From the user-interface point of view, you specify a comma-separated list of Solr server URLs instead of a single one in the solrindexer job. Under the covers, the job starts up a list of Solr servers, each with its own document queue. A partitioner checks which server a document will go to based on its key. Each time an input queue becomes larger than a specified size (the commit interval), a commit is called on the appropriate Solr server. Once all the URLs are consumed, a commit is called on all the Solr servers in the list.

You can find my patch (for NutchGora branch only) in NUTCH-945. The discussion that led to this change can be found here.

I also put in the same change to my custom sub-page indexer (originally described here). The changes are only in the reducer, so I have removed the mapper code for brevity. You can get the mapper code from the previous post from the link referenced in this paragraph.

  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
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
// Source: src/java/com/mycompany/nutch/subpageindexer/SolrSubpageIndexerJob.java
package com.mycompany.nutch.subpageindexer;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Date;
import java.util.HashSet;
import java.util.List;
import java.util.Map;

import org.apache.avro.util.Utf8;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang.StringUtils;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.gora.mapreduce.GoraMapper;
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Partitioner;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.partition.HashPartitioner;
import org.apache.hadoop.util.ToolRunner;
import org.apache.nutch.indexer.IndexerJob;
import org.apache.nutch.indexer.NutchDocument;
import org.apache.nutch.indexer.solr.NonPartitioningPartitioner;
import org.apache.nutch.indexer.solr.SolrConstants;
import org.apache.nutch.metadata.Nutch;
import org.apache.nutch.storage.Mark;
import org.apache.nutch.storage.StorageUtils;
import org.apache.nutch.storage.WebPage;
import org.apache.nutch.util.Bytes;
import org.apache.nutch.util.NutchConfiguration;
import org.apache.nutch.util.NutchJob;
import org.apache.nutch.util.TableUtil;
import org.apache.nutch.util.ToolUtil;
import org.apache.solr.client.solrj.SolrServer;
import org.apache.solr.client.solrj.SolrServerException;
import org.apache.solr.client.solrj.impl.CommonsHttpSolrServer;
import org.apache.solr.common.SolrInputDocument;
import org.apache.solr.common.util.DateUtil;
import org.codehaus.jackson.map.ObjectMapper;
import org.codehaus.jackson.type.TypeReference;

public class SolrSubpageIndexerJob extends IndexerJob {

  private static Log LOG = LogFactory.getLog(SolrSubpageIndexerJob.class);
  
  private static final Collection<WebPage.Field> FIELDS = 
    new HashSet<WebPage.Field>();
  
  static {
    FIELDS.addAll(Arrays.asList(WebPage.Field.values()));
  }
  
  public static class SolrSubpageIndexerJobMapper 
      extends GoraMapper<String,WebPage,Text,NutchDocument> {
    // ... no changes here ...
  }
  
  public static class SolrSubpageIndexerJobReducer
      extends Reducer<Text,NutchDocument,Text,NutchDocument> {
   
    private int commitSize;
    private SolrServer[] servers;
    private Partitioner<String,NutchDocument> partitioner;
    private List<SolrInputDocument>[] sdocs = null; 
    
    @SuppressWarnings("unchecked")
    @Override
    public void setup(Context ctx) throws IOException {
      Configuration conf = ctx.getConfiguration();
      String[] urls = conf.getStrings(SolrConstants.SERVER_URL);
      if (urls.length == 0) {
        throw new IOException(SolrConstants.SERVER_URL + " not configured");
      }
      this.servers = new SolrServer[urls.length];
      this.sdocs = (ArrayList<SolrInputDocument>[]) 
        new ArrayList[urls.length];
      for (int i = 0; i < urls.length; i++) {
        servers[i] = new CommonsHttpSolrServer(urls[i]);
        sdocs[i] = new ArrayList<SolrInputDocument>();
      }
      commitSize = conf.getInt(SolrConstants.COMMIT_SIZE, 1000);
      if (urls.length == 1) {
        partitioner = new NonPartitioningPartitioner();
      } else {
        try {
          String partitionerClass = conf.get(SolrConstants.PARTITIONER_CLASS);
          partitioner = (Partitioner<String,NutchDocument>) 
            Class.forName(partitionerClass).newInstance();
          LOG.info("Partitioning using: " + partitionerClass);
        } catch (Exception e) {
          partitioner = new HashPartitioner<String, NutchDocument>();
          LOG.info("Partitioning using default HashMod partitioner");
        }
      }
      this.commitSize = conf.getInt(SolrConstants.COMMIT_SIZE, 1000);
    }
    
    @Override
    public void reduce(Text key, Iterable<NutchDocument> values,
        Context ctx) throws IOException, InterruptedException {
      for (NutchDocument doc : values) {
        SolrInputDocument sdoc = new SolrInputDocument();
        for (String fieldname : doc.getFieldNames()) {
          sdoc.addField(fieldname, doc.getFieldValue(fieldname));
        }
        int partition = partitioner.getPartition(
          key.toString(), doc, sdocs.length);
        sdocs[partition].add(sdoc);
        if (sdocs[partition].size() >= commitSize) {
          try {
            servers[partition].add(sdocs[partition]);
          } catch (SolrServerException e) {
            throw new IOException(e);
          }
          sdocs[partition].clear();
        }
      }
    }
    
    @Override
    public void cleanup(Context ctx) throws IOException {
      for (int i = 0; i < sdocs.length; i++) {
        try {
          if (sdocs[i].size() > 0) {
            servers[i].add(sdocs[i]);
          }
          sdocs[i].clear();
          servers[i].commit();
        } catch (SolrServerException e) {
          throw new IOException(e);
        }
      }
    }
  }
  
  @Override
  public Map<String,Object> run(Map<String,Object> args) throws Exception {
    String solrUrl = (String) args.get(SolrConstants.SERVER_URL);
    if (StringUtils.isNotEmpty(solrUrl)) {
      getConf().set(SolrConstants.SERVER_URL, solrUrl);
    }
    String batchId = (String) args.get(Nutch.ARG_BATCH);
    if (StringUtils.isNotEmpty(batchId)) {
      getConf().set(Nutch.ARG_BATCH, batchId);
    }
    currentJob = new NutchJob(getConf(), "solr-subpage-index");
    StorageUtils.initMapperJob(currentJob, FIELDS, Text.class, 
      NutchDocument.class, SolrSubpageIndexerJobMapper.class);
    currentJob.setMapOutputKeyClass(Text.class);
    currentJob.setMapOutputValueClass(NutchDocument.class);
    currentJob.setReducerClass(SolrSubpageIndexerJobReducer.class);
    currentJob.setNumReduceTasks(5);
    currentJob.waitForCompletion(true);
    ToolUtil.recordJobStatus(null, currentJob, results);
    return results;
  }

  @Override
  public int run(String[] args) throws Exception {
    if (args.length < 2) {
      System.err.println("Usage: SolrSubpageIndexerJob <solr url> (<batch_id> | -all)");
      return -1;
    }
    LOG.info("SolrSubpageIndexerJob: starting");
    run(ToolUtil.toArgMap(
      SolrConstants.SERVER_URL, args[0],
      Nutch.ARG_BATCH, args[1]));
    LOG.info("SolrSubpageIndexerJob: success");
    return 0;
  }

  public static void main(String[] args) throws Exception {
    final int res = ToolRunner.run(NutchConfiguration.create(), 
      new SolrSubpageIndexerJob(), args);
    System.exit(res);
  }
}

If you want to specify your own custom partitioner, then you will need to define it in your nutch-site.xml file. Here is an example from mine:

1
2
3
4
5
<property>
  <name>solr.partitioner.class</name>
  <value>com.mycompany.nutch.indexer.solr.MurmurHashPartitioner</value>
  <description>Custom partitioner for distributed Solr index</description>
</property>

I set up clones of Solr by copying my non-distributed Solr server bin distribution directory (with the nutch version of schema.xml, updated as described in previous posts), deleting the contents of the data directory, and running each on their own ports, like so:

1
2
3
4
5
6
7
sujit@cyclone:NutchGora$ # On one terminal
sujit@cyclone:NutchGora$ cd solr1/example
sujit@cyclone:example$ java -Djetty.port=8984 -jar start.jar
sujit@cyclone:example$ 
sujit@cyclone:NutchGora$ # On another terminal
sujit@cyclone:NutchGora$ cd solr2/example
sujit@cyclone:example$ java -Djetty.port=8985 -jar start.jar

Once you apply the batch and build the runtime, you can run the solrindexer and SolrSubpageIndexer jobs from the command line like so:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
sujit@cyclone:local$ # indexing single-solr mode to port 8983
sujit@cyclone:local$ bin/nutch solrindex http://localhost:8983/solr -all
sujit@cyclone:local$ 
sujit@cyclone:local$ # indexing distributed-solr mode to ports 8984/8985
sujit@cyclone:local$ bin/nutch solrindex 
      http://localhost:8984/solr,http://localhost:8985/solr -all
sujit@cyclone:local$ 
sujit@cyclone:local$ # indexing subpages single-solr mode to port 8983
sujit@cyclone:local$ bin/nutch \
      com.mycompany.nutch.subpageindexer.SolrSubpageIndexerJob \
      http://localhost:8983/solr -all
sujit@cyclone:local$ 
sujit@cyclone:local$ # indexing subpages distrib-solr mode to ports 8984/8985
sujit@cyclone:local$ bin/nutch \
      com.mycompany.nutch.subpageindexer.SolrSubpageIndexerJob \
      http://localhost:8984/solr,http://localhost:8985/solr -all

I have intentionally shown the commands for the single-solr indexing version to illustrate that the change is fully backward compatible, and also because I wanted to compare the search results between the non-distributed (port 8983) and distributed (ports 8984 and 8985) environments.

Search

Solr (version 3.5) which I am using supports distributed search via sharding out of the box. The Solr Distributed Search wiki page has more information about it. But to enable distributed search on a query (provided its handler is not using any of the unsupported components), is as easy as adding a shards parameter to your URL, which contains a comma-separated list of Solr servers. In my setup, my shards parameter would look like shards=localhost:8984/solr,localhost:8985/solr.

To support sharding in my Python client (described here), all I needed to do was declare my SOLR_SHARDS value and add the shards to my solrparams tuple list (around line 141), so its passed back to Solr. Also since I am pointing to (8984,8985) now, my query has to hit one of these servers instead of 8983 (hardcoded in SOLR_SERVER) so that should be changed too.

1
2
3
4
5
    SOLR_SERVER = "http://localhost:8984/solr/select"
    SOLR_SHARDS = ["localhost:8984/solr", "localhost:8985/solr"]
    ...
    # finally, add the shards parameters
    solrparams.append(tuple(["shards", ",".join(SOLR_SHARDS)]))

I set up two copies of my CherryPy based client applications, one running against the single-Solr instance on port 8983 and listening on port 8081, and another one running against the distributed Solr instances on ports 8984 and 8985 and listening on port 8082, and compared results from sending the same query to both applications. Below are some screenshots - as you can see, results are identical (which is expected, of course).

From what I see from the logs, the response handler that is invoked with the sharded query (/select on port 8984 in our case), intercepts the shards parameter and forwards the query to each shard, with the shards parameter replaced with isShard=true. Once it gets back all the responses, it joins them back and presents it back to the caller.

Friday, February 17, 2012

Some Dev Pycassa Scripts for Nutch-GORA with Cassandra

Over the last few weeks, I've been tinkering with Nutch-GORA with the Cassandra store. During that time, I've built a couple of simple (but useful) Pycassa scripts that I would like to share here, with the hope that it helps someone doing similar stuff.

But first, a little digression... Building these scripts forced me to look beyond the clean JavaBean-style view of WebPage that the GORA ORM provides to Nutch-GORA. It has also led me to some insights about Column databases and Cassandra, which I would also like to share, because I believe it will help you understand the scripts more easily. If you are already familiar (conceptually, at least) with how Cassandra stores its data, then feel free to skip this bit, you probably have a better mental model of this already than I can describe. But to me, it was an Eureka moment, and a lot of things which I didn't quite understand when I read this post long ago fell into place.

Consider the gora-cassandra-mapping.xml available in nutch/conf. It describes the schema of the WebPage object in terms of two column families and a super-column family, like so:

 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
<?xml version="1.0" encoding="UTF-8"?>
<gora-orm>
    <keyspace name="webpage" cluster="Test Cluster" host="localhost">
        <family name="f"/>
        <family name="p"/>
        <family name="sc" type="super"/>
    </keyspace>
    <class keyClass="java.lang.String" name="org.apache.nutch.storage.WebPage">
        <!-- fetch fields -->
        <field name="baseUrl" family="f" qualifier="bas"/>
        <field name="status" family="f" qualifier="st"/>
        <field name="prevFetchTime" family="f" qualifier="pts"/>
        <field name="fetchTime" family="f" qualifier="ts"/>
        <field name="fetchInterval" family="f" qualifier="fi"/>
        <field name="retriesSinceFetch" family="f" qualifier="rsf"/>
        <field name="reprUrl" family="f" qualifier="rpr"/>
        <field name="content" family="f" qualifier="cnt"/>
        <field name="contentType" family="f" qualifier="typ"/>
        <field name="modifiedTime" family="f" qualifier="mod"/>
        <!-- parse fields -->
        <field name="title" family="p" qualifier="t"/>
        <field name="text" family="p" qualifier="c"/>
        <field name="signature" family="p" qualifier="sig"/>
        <field name="prevSignature" family="p" qualifier="psig"/>
        <!-- score fields -->
        <field name="score" family="f" qualifier="s"/>
        <!-- super columns -->
        <field name="markers" family="sc" qualifier="mk"/>
        <field name="inlinks" family="sc" qualifier="il"/>
        <field name="outlinks" family="sc" qualifier="ol"/>
        <field name="metadata" family="sc" qualifier="mtdt"/>
        <field name="headers" family="sc" qualifier="h"/>
        <field name="parseStatus" family="sc" qualifier="pas"/>
        <field name="protocolStatus" family="sc" qualifier="prs"/>
    </class>
</gora-orm>

JSON is the best way to visualize the structure of a Column database, so a JSON-like view of a single record (generated by one of the scripts I am about to describe later) would look something like this. A JavaBean representation of this JSON-like structure is what you see from within Nutch-GORA code.

 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
webpage: {
   key: "localhost:http:8080/provider/prov1__1__002385.xml" ,
   f: {
     bas : "http://localhost:8080/provider/prov1__1__002385.xml" ,
     cnt : "Original Content as fetched goes here",
     fi : "2592000" ,
     s : "3.01568E-4" ,
     st : "2" ,
     ts : "1338943926088" ,
     typ : "application/xml" ,
   },
   p: {
     c : "Parsed Content (after removing XML tags, etc) goes here",
     psig : "fffd140178fffdbfffd724fffd13fffd79553b" ,
     sig : "fffd140178fffdbfffd724fffd13fffd79553b" ,
     t : "Ions" ,
   },
   sc: {
     h : {
       Content-Length : "3263" ,
       Content-Type : "application/xml" ,
       Date : "Tue, 07 Feb 2012 00:52:06 GMT" ,
       Last-Modified : "Fri, 20 Jan 2012 00:04:19 GMT" ,
       Server : "CherryPy/3.1.2" ,
     }
     il : {
       http://localhost:8080/provider_index/prov1-sitemap.xml : \
         "http://localhost:8080/provider/prov1__1__002385.xml" ,
     }
     mk : {
       __prsmrk__ : "1328561300-1185343131" ,
       _ftcmrk_ : "1328561300-1185343131" ,
       _gnmrk_ : "1328561300-1185343131" ,
       _idxmrk_ : "1328561300-1185343131" ,
       _updmrk_ : "1328561300-1185343131" ,
     }
     mtdt : {
       _csh_ : "0000" ,
       u_category : "SpecialTopic" ,
       u_contentid : "002385" ,
       u_disp : "M" ,
       u_idx : "prov1" ,
       u_lang : "en" ,
       u_reviewdate : "2009-08-09T00:00:00.000Z" ,
     }
     pas : {
       majorCode : "1" ,
       minorCode : "0" ,
     }
     prs : {
       code : "1" ,
       lastModified : "0" ,
     }
   }
}

The scripts, however, would see zero to three maps (columns are optional, remember), all three accessible by the key ${key} and a secondary key. The secondary key is the column family (or super column family) name, "f", "p" or "sc". So WebPage[$key]["f"] would retrieve a map of columns and the values for the particular WebPage's f-column family. The super-column family provides an additional level of nesting, ie, the WebPage[$key]["sc"] returns a map of column families.

So the WebPage that GORA produces is actually built out of three independent entities, all accessible with the same key. There, thats it, end of brilliant insight :-). Hope that wasn't too much of a disappointment.

Display Records

The first script is a script that scans the WebPage keyspace, and for each key, gets the columns from the "f", "p" and "sc" column families and displays them in the JSON like structure shown above. Without any parameters, it produces a ump of the entire WebPage (to standard out). You can also get a list of all the keys by passing in a "-s" switch. Alternatively, you can dump out a single WebPage record by passing in the key as a parameter.

So you can dump out the database, list all the keys, or dump out a single record. Its useful to be able to "see" a record during development, for example, to see that you've got all the fields right. Heres the code:

 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
#!/usr/bin/python

import getopt
import sys

import pycassa
from pycassa.pool import ConnectionPool
from pycassa.util import OrderedDict

def print_map(level, dict):
  for key in dict.keys():
    value = dict[key]
    if type(value) == type(OrderedDict()):
      print indent(level), key, ": {"
      print_map(level+1, value)
      print indent(level), "}"
    elif key == "sig" or key == "psig" or key == "_csh_":
      # these don't render well even though we do decode
      # unicode to utf8, so converting to hex
      print indent(level), key, ":", quote(to_hex_string(value)), ","
    else:
      print indent(level), key, ":", quote(value), ","
    
def to_hex_string(s):
  chars = []
  for i in range(0, len(s)):
    chars.append(hex(ord(s[i:i+1]))[2:])
  return "".join(chars)

def quote(s):
  if not(s.startswith("\"") and s.endswith("\"")):
    return "".join(["\"", unicode(s).encode("utf8"), "\""])
  else:
    return s

def indent(level):
  return ("." * level * 2)

def usage(message=None):
  print "Usage: %s [-h|-s] [key]" % (sys.argv[0])
  print "-h|--help: show this message"
  print "-s|--summary: show only keys"
  sys.exit(-1)
  
def main():
  try:
    (opts, args) = getopt.getopt(sys.argv[1:], "sh", \
      ["summary", "help"])
  except getopt.GetoptError:
    usage()
  show_summary = False
  for opt in opts:
    (k, v) = opt
    if k in ["-h", "--help"]:
      usage()
    if k in ["-s", "--summary"]:
      show_summary = True
  key = "" if len(args) == 0 else args[0]
  if not show_summary:
    print "webpage: {"
  level = 1
  pool = ConnectionPool("webpage", ["localhost:9160"])
  f = pycassa.ColumnFamily(pool, "f")
  for fk, fv in f.get_range(start=key, finish=key):
    print indent(level), "key:", quote(fk), ","
    if show_summary == True:
      continue
    print indent(level), "f: {"
    if type(fv) == type(OrderedDict()):
      print_map(level+1, fv)
    else:
      print indent(level+1), fk, ":", quote(fv), ","
    print indent(level), "},"
    p = pycassa.ColumnFamily(pool, "p")
    print indent(level), "p: {"
    for pk, pv in p.get_range(start=fk, finish=fk):
      if type(pv) == type(OrderedDict()):
        print_map(level+1, pv)
      else:
        print indent(level+1), pk, ":", quote(pv), ","
    print indent(level), "},"
    sc = pycassa.ColumnFamily(pool, "sc")
    print indent(level), "sc: {"
    for sck, scv in sc.get_range(start=fk, finish=fk):
      if type(scv) == type(OrderedDict()):
        print_map(level+1, scv)
      else:
        print indent(level+1), sck, ":", quote(scv), ","
    print indent(level), "}"
  if not show_summary:
    print "}"

if __name__ == "__main__":
  main()

Reset Status Marks

This script resets the marks that Nutch puts into the WebPage[$key]["sc"]["mk"] column family after each stage (generate, fetch, parse, updatedb). This is useful if you want to redo some operation. Without this, the only way (that I know of anyway) is to drop the keyspace, then redo the steps till that point. This works okay for smaller datasets, but becomes old really quick when dealing with even moderately sized datasets (I am working with a 6000 page collection).

Even though I am fetching off a local (CherryPy) server, it takes a while. I guess I could have just gone with a faster HTTPD server, but this script saved me a lot of time while I was debugging my parsing plugin code, by allowing me to reset and redo the parse and updatedb steps over and over until I got it right. Heres the code:

 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
#!/usr/bin/python

import sys

import pycassa
from pycassa.pool import ConnectionPool

marks = {
  "generate" : "_gnmrk_",
  "fetch" : "_ftcmrk_",
  "parse" : "__prsmrk__",
  "updatedb" : "_updmrk_",
}

def usage(msg=None):
  print "Usage: %s stage batch-id|-all" % (sys.argv[0])
  print "Stages: %s" % (marks.keys())
  sys.exit(-1)

def main():
  if len(sys.argv) != 3:
    usage()
  mark = None
  try:
    mark = marks[sys.argv[1]]
  except KeyError:
    usage("Unknown stage: %s" % (sys.argv[1]))
  batchid = sys.argv[2]
  pool = ConnectionPool("webpage", ["localhost:9160"])
  sc = pycassa.ColumnFamily(pool, "sc")
  for sck, scv in sc.get_range(start="", finish=""):
    reset = False
    if batchid != "-all":
      # make sure the mark is what we say it is before deleting
      try:
        rbatchid = scv["mk"][mark]
        if batchid == rbatchid:
          reset = True
      except KeyError:
        continue
    else:
      reset = True
    if reset == True:
      print sck
      print "Reset %s for key: %s" % (mark, sck)
      sc.remove(sck, columns=[mark], super_column="mk")

if __name__ == "__main__":
  main()

To call this you supply the stage (one of generate, fetch, parse or updatedb) as the first argument, and either a batch ID or -all for all batch IDs, and the script will "reset" the appropriate WebPage[$key]["sc"]["mk"] by deleting the appropriate column for all the WebPage column families in the keyspace.

And that's it for today. Hope you find the scripts helpful.

Friday, February 10, 2012

Nutch/GORA - Using a sitemap to seed a site

The genesis of this "feature" was due to my misunderstanding of how Nutch works. So far I've been running fairly small batches as I built my plugin and other application specific custom code. But I was now at the point where I could try ingesting all the XML files provided by this provider, so I did. There are about 6000 XML files in this collection, but Nutch fetched exactly 318. Every time (I tried it couple of times to make sure I was doing it right).

I initially thought that perhaps because the seed list for the provider was in HTML, Nutch's default HTML parser was doing some magic "above the fold" scoring that discounted items further down the page, so I hit upon the idea of using a sitemap XML file. I figured that since Nutch didn't provide sitemap support, I'd have to write my own parser (which wouldn't have any magic scoring). Since my XML parser plugin already allowed for multiple parsers, this just involves writing a sitemap XML processor and calling it through my plugin.

Of course, this did not fix the problem, the fetcher just stopped after a different number of files. Turns out that by default, Nutch only reads the first 64KB of the file and drops the rest. A quick peek at my webpage["f"]["cnt"] in the database confirmed this. So the fix for my original problem was really just adding this block to my nutch-site.xml file:

1
2
3
4
5
6
7
8
9
<property>
  <name>http.content.limit</name>
  <value>-1</value>
  <description>The length limit for downloaded content using the http
  protocol, in bytes. If this value is nonnegative (>=0), content longer
  than it will be truncated; otherwise, no truncation at all. Do not
  confuse this setting with the file.content.limit setting.
  </description>
</property>

But I had already written the sitemap parser, and using a seed file in sitemap format would allow me to also support vertical crawls for partner sites (who typically provide us a URL to their sitemap) with the same infrastructure that I am building for ingesting provider XML files. So I decided to go with it.

The sitemap format is quite simple, but the only information thats usable during the inject stage is the urlset/url/loc value. The provider_index method in the updated CherryPy server code below generates a dynamic sitemap XML from the contents of the filesystem.

 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
#!/usr/bin/python
import cherrypy
import os
import os.path
import urllib

from cherrypy.lib.static import serve_file

SITES_DIR = "/path/to/your/sites/directory"
SERVER_HOST = "localhost"
SERVER_PORT = 8080

def _accumulate_files(files, dirname, fnames):
  """
  This function gets called for every file (and directory) that is walked
  by os.path.walk. It accumulates the file names found into a flat array.
  The file names accumulated are relative to the providers directory.
  """
  for fname in fnames:
    abspath = os.path.join(dirname, fname)
    if os.path.isfile(abspath):
      abspath = abspath.replace(os.path.join(SITES_DIR, "providers"), "")[1:]
      files.append(abspath)

class Root:

  @cherrypy.expose
  def test(self, name):
    """
    Expose the mock site for testing.
    """
    return serve_file(os.path.join(SITES_DIR, "test", "%s.html" % (name)), \
      content_type="text/html")

  @cherrypy.expose
  def provider_index(self, name):
    """
    Builds an index page of links to all the files for the specified
    provider. The files are stored under sites/providers/$name. The
    function will recursively walk the filesystem under this directory
    and dynamically generate a flat list of links. Path separators in
    the filename are converted to "__" in the URL. The index page can
    be used as the seed URL for this content.
    """
    files = []
    name = name.replace("-sitemap.xml", "")
    os.path.walk(os.path.join(SITES_DIR, "providers", name), \
      _accumulate_files, files)
    index = """<?xml version=\"1.0\" encoding=\"UTF-8\"?>
<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">
    """
    for file in files:
      url = "http://%s:%s/provider/%s" % (SERVER_HOST, SERVER_PORT, \
        urllib.quote_plus(file.replace(os.path.sep, "__")))
      index += """
<url><loc>%s</loc></url>
      """ % (url)
    index += """
</urlset>
    """
    cherrypy.response.headers["Content-Type"] = "application/xml"
    return [index]

  @cherrypy.expose
  def provider(self, name):
    """
    Returns the contents of the XML file stored at the location 
    corresponding to the URL provided. The "__" in the URL are converted
    back to file path separators.
    """
    ct = None
    if name.endswith(".xml"):
      ct = "application/xml"
    elif name.endswith(".json"):
      ct = "application/json"
    if ct is None:
      return serve_file(os.path.join(SITES_DIR, "providers", \
        "%s" % name.replace("__", os.path.sep)), \
        content_type = "text/html")
    else:
      return serve_file(os.path.join(SITES_DIR, "providers", \
        "%s" % (urllib.unquote_plus(name).replace("__", os.path.sep))), \
        content_type = ct)

if __name__ == '__main__':
  current_dir = os.path.dirname(os.path.abspath(__file__))
  # Set up site-wide config first so we get a log if errors occur.
  cherrypy.config.update({'environment': 'production',
    'log.access_file': 'site.log',
    'log.screen': True,
    "server.socket_host" : SERVER_HOST,
    "server.socket_port" : SERVER_PORT})
  cherrypy.quickstart(Root(), '/')

The ProviderXmlProcessorFactory (described in a previous post) was modified slightly to check for the basename of the URL. If the basename contains the string "sitemap", then it will delegate to the Sitemap Processing component first, and only then look at the value of the u_idx metadata field. The code for this is shown below:

 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
// Source: src/plugin/mycompany/src/java/com/mycompany/nutch/parse/xml/sitemap/SitemapXmlProcessor.java
package com.mycompany.nutch.parse.xml.sitemap;

import java.io.ByteArrayInputStream;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.codehaus.jackson.map.ObjectMapper;
import org.jdom.Document;
import org.jdom.Element;
import org.jdom.Namespace;
import org.jdom.input.SAXBuilder;
import org.xml.sax.InputSource;

import com.mycompany.nutch.parse.xml.IProviderXmlProcessor;
import com.mycompany.nutch.parse.xml.ProviderXmlFields;

public class SitemapXmlProcessor implements IProviderXmlProcessor {

  public static final String OUTLINKS_KEY = "_outlinks";
  
  private ObjectMapper mapper;
  
  public SitemapXmlProcessor() {
    mapper = new ObjectMapper();
  }
  
  @SuppressWarnings("unchecked")
  @Override
  public Map<String,String> parse(String content) throws Exception {
    Map<String,String> parsedFields = new HashMap<String,String>();
    SAXBuilder builder = new SAXBuilder();
    Document doc = builder.build(new InputSource(
      new ByteArrayInputStream(content.getBytes())));
    Element root = doc.getRootElement();
    Namespace ns = root.getNamespace();
    if ("urlset".equals(root.getName())) {
      List<String> urls = new ArrayList<String>();
      List<Element> eUrls = root.getChildren("url", ns);
      for (Element eUrl : eUrls) {
        urls.add(eUrl.getChildTextTrim("loc", ns));
        // sitemap 0.9 also specifies optional elements lastmod,
        // changefreq and priority. The first two could be handled
        // if we change the OutLink object to hold these values
        // as metadata, which is used to update the modified
        // and fetchInterval values in the outlink once its put
        // into the fetchlist. But we ignore these currently.
      }
      parsedFields.put(OUTLINKS_KEY, mapper.writeValueAsString(urls));
      // set some fields to prevent nutch from choking on NPEs
      parsedFields.put(ProviderXmlFields.title.name(), "sitemap");
      parsedFields.put(ProviderXmlFields.content.name(), "sitemap");
    }
    return parsedFields;
  }
}

As you can see, it parses out the URLs and accumulates them into a List, whcih is then written out to the parsedFields map as a JSON string keyed by a magic key "_outlinks". The ProviderXmlParser plugin does a bit of special processing for this key, specifically it converts the JSON string back to a List and writes the list elements out to the webpage["f"]["ol"] column. Here is the modified ProviderXmlParser class.

  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
// Source: src/plugin/mycompany/src/java/com/mycompany/nutch/parse/xml/ProviderXmlParser.java
package com.mycompany.nutch.parse.xml;

import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.apache.avro.util.Utf8;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.hadoop.conf.Configuration;
import org.apache.nutch.parse.Outlink;
import org.apache.nutch.parse.Parse;
import org.apache.nutch.parse.ParseStatusCodes;
import org.apache.nutch.parse.Parser;
import org.apache.nutch.storage.ParseStatus;
import org.apache.nutch.storage.WebPage;
import org.apache.nutch.storage.WebPage.Field;
import org.apache.nutch.util.Bytes;
import org.codehaus.jackson.map.ObjectMapper;
import org.codehaus.jackson.type.TypeReference;

import com.mycompany.nutch.parse.xml.sitemap.SitemapXmlProcessor;

public class ProviderXmlParser implements Parser {

  private static final Log LOG = LogFactory.getLog(ProviderXmlParser.class); 
  private static final Set<WebPage.Field> FIELDS = new HashSet<WebPage.Field>();
  private static final Utf8 IDX_KEY = new Utf8("u_idx");
  
  static {
    FIELDS.add(WebPage.Field.METADATA);
    FIELDS.add(WebPage.Field.OUTLINKS);
  }

  private Configuration conf;
  private ObjectMapper mapper;
  private TypeReference<List<String>> outlinksTypeRef;
  
  public ProviderXmlParser() {
    this.mapper = new ObjectMapper();
    this.outlinksTypeRef = new TypeReference<List<String>>() {};
  }
  
  @Override
  public Parse getParse(String url, WebPage page) {
    Parse parse = new Parse();
    parse.setParseStatus(new ParseStatus());
    parse.setOutlinks(new Outlink[0]);
    Map<Utf8,ByteBuffer> metadata = page.getMetadata();
    if (metadata.containsKey(IDX_KEY)) {
      String idx = Bytes.toString(Bytes.toBytes(metadata.get(IDX_KEY)));
      IProviderXmlProcessor processor = ProviderXmlProcessorFactory.getProcessor(url, idx);
      if (processor != null) {
        try {
          LOG.info("Parsing URL:[" + url + "] with " + 
            processor.getClass().getSimpleName());
          Map<String,String> parsedFields = processor.parse(
              Bytes.toString(Bytes.toBytes(page.getContent())));
          parse.setText(parsedFields.get(ProviderXmlFields.content.name()));
          parse.setTitle(parsedFields.get(ProviderXmlFields.title.name()));
          // set the rest of the metadata back into the page
          for (String key : parsedFields.keySet()) {
            if (ProviderXmlFields.content.name().equals(key) ||
                ProviderXmlFields.title.name().equals(key) ||
                SitemapXmlProcessor.OUTLINKS_KEY.equals(key)) {
              continue;
            }
            page.putToMetadata(new Utf8(key), 
              ByteBuffer.wrap(parsedFields.get(key).getBytes()));
          }
          if (parsedFields.containsKey(
              SitemapXmlProcessor.OUTLINKS_KEY)) {
            // if we have OUTLINKS data, then populate it as well
            List<String> outlinkUrls = mapper.readValue(
              parsedFields.get(SitemapXmlProcessor.OUTLINKS_KEY), 
              outlinksTypeRef);
            Outlink[] outlinks = new Outlink[outlinkUrls.size()];
            for (int i = 0; i < outlinks.length; i++) {
              String outlinkUrl = outlinkUrls.get(i);
              outlinks[i] = new Outlink(outlinkUrl, outlinkUrl);
            }
            parse.setOutlinks(outlinks);
          }
          parse.getParseStatus().setMajorCode(ParseStatusCodes.SUCCESS);
        } catch (Exception e) {
          LOG.warn("Parse of URL: " + url + " failed", e);
          parse.getParseStatus().setMajorCode(ParseStatusCodes.FAILED);
        }
      }
    }
    return parse;
  }

  @Override
  public Collection<Field> getFields() {
    return FIELDS;
  }

  @Override
  public Configuration getConf() {
    return conf;
  }

  @Override
  public void setConf(Configuration conf) {
    this.conf = conf;
  }
}

And thats pretty much it. We now inject the following seed URL for this provider, and run through two iterations (depth 2) of the generate, fetch, parse and updatedb, and as expected, all the provider XML files are ingested without problems.

1
http://localhost:8080/provider_index/prov1-sitemap.xml u_idx=prov1

The approach I describe is probably not what you would normally think of when you hear "nutch" and "sitemap enabled" in the same sentence. After all, we are throwing away the optional metadata that is being provided to us, such as crawl frequency and last modified time. Unfortunately with the approach I have chosen - using the sitemap XML file as the seed URL - the only way I know of to ingest this information in a single pass is to change the Outlink data structure. However, there is nothing preventing you from making a second pass over the sitemap after the fetch and then resetting the fetch interval for a page based on its sitemap properties - sort of like my Delta Indexer on auto-pilot.