Sunday, July 28, 2013

Moving the MySQL Tmpdir on Ubuntu

One thing I frequently forget when changing the default directories for various services in Ubuntu is that the AppArmor config for those services also needs to be updated. Case in point, the other day I needed to change MySQL's tmpdir to a different disk, and struggled for a while with "Can't create/write to file 'xyz' (Errcode: 13)" errors until I remembered AppArmor. These were the steps I ended up taking:

Create the new tmpdir
mkdir /mnt/foo/tmp && sudo chown mysql:mysql /mnt/foo/tmp
Change /etc/mysql/my.cnf to use the new tmpdir
tmpdir = /mnt/foo/tmp
Add new tmpdir entries to /etc/apparmor.d/local/usr.sbin.mysqld
/mnt/foo/tmp/ r,
/mnt/foo/tmp/** rw,
Reload AppArmor
sudo service apparmor reload
Restart MySQL
sudo service mysql restart

While I was troubleshooting, I found a nice, in-depth blog entry by Jeremy Smyth explaining how to debug issues with AppArmor and MySQL.

Sunday, July 21, 2013

Tuning Lucene to Get the Most Relevant Results

Just spent the last week tuning our search engine using the latest version of Lucene (4.3.1). While Lucene works amazingly well right out of the box, to get "Google-like" relevancy for your results, you usually need to devise a custom strategy for indexing and querying the particular content your application has. Here are a few tricks we used for our content (which is English-only, jargon-heavy, and contains many terms used only by a few documents), plus some more basic techniques that just took us a while to figure out:

Use a custom analyzer for English text

Lucene's StandardAnalyzer does a good job generally of tokenizing text into individual words (aka "terms"), and it skips English "stopwords" (like the, a, etc) by default — but if you have only English text, you can get better results by using the EnglishAnalyzer. Beyond the tokenizing filters that the StandardAnalyzer includes, the EnglishAnalyzer also includes the EnglishPossesiveFilter (for stripping 's from words) and the PorterStemFilter (for chopping off common word suffixes, like removing ming from stemming, etc).

Because some of our text includes non-English names with letters not in the English alphabet (like é in liberté), and we know our users are going to want to search for those names using just English-alphabet letters, we implemented our own analyzer that included the ASCIIFoldingFilter on top of the filters in the regular EnglishAnalyzer. This filter converts characters not in the (7-byte) ASCII range to the ASCII characters that they resemble most closely; for example, it converts é to e (and © to (c), etc).

A custom analyzer is easy to implement; this is what ours looks like in java (the matchVersion and stopwords variables are fields from its Analyzer and StopwordAnalyzerBase superclasses, and the TokenStreamComponents is an inner class of Analyzer):

import java.io.Reader;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.Tokenizer;
import org.apache.lucene.analysis.core.LowerCaseFilter;
import org.apache.lucene.analysis.core.StopFilter;
import org.apache.lucene.analysis.en.EnglishPossessiveFilter;
import org.apache.lucene.analysis.en.PorterStemFilter;
import org.apache.lucene.analysis.miscellaneous.ASCIIFoldingFilter;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.analysis.standard.StandardFilter;
import org.apache.lucene.analysis.standard.StandardTokenizer;
import org.apache.lucene.analysis.util.StopwordAnalyzerBase;
import org.apache.lucene.util.Version;

public class CustomEnglishAnalyzer extends StopwordAnalyzerBase {

    /** Tokens longer than this length are discarded. Defaults to 50 chars. */
    public int maxTokenLength = 50;

    public CustomEnglishAnalyzer() {
        super(Version.LUCENE_43, StandardAnalyzer.STOP_WORDS_SET);
    }

    @Override
    protected TokenStreamComponents createComponents(String fieldName, Reader reader) {
        final Tokenizer source = new StandardTokenizer(matchVersion, reader);
        source.setMaxTokenLength(maxTokenLength);

        TokenStream pipeline = source;
        pipeline = new StandardFilter(matchVersion, pipeline);
        pipeline = new EnglishPossessiveFilter(matchVersion, pipeline);
        pipeline = new ASCIIFoldingFilter(pipeline);
        pipeline = new LowerCaseFilter(matchVersion, pipeline);
        pipeline = new StopFilter(matchVersion, pipeline, stopwords);
        pipeline = new PorterStemFilter(pipeline);
        return new TokenStreamComponents(source, pipeline);
    }
}

Note that when you use a custom analyzer for indexing, it's important to use the same (or least a similar) analyzer for querying (and vice versa). For example, the EnglishAnalyzer will tokenize the phrase it's easily processed into two terms: easili (sic) and process. If you index this text with the EnglishAnalyzer, searching for the terms it's, easily, or processed will find no results — you have to create the query using the same analyzer to make sure the terms for which you're querying are actually easili and process.

You can use Lucene's StandardQueryParser to build an appropriate query for you out of a phrase, using Lucene's fancy querying syntax; or you can simply tokenize the phrase yourself with the following code, and build the query out of it yourself:

import java.util.ArrayList;
import java.util.List;
import java.io.StringReader;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.en.EnglishAnalyzer;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.util.Version;

...

    List<String> tokenizePhrase(String phrase) {
        List<String> tokens = new ArrayList<String>();
        TokenStream stream = new EnglishAnalyzer(Version.LUCENE_43).tokenStream(
            "someField", new StringReader(phrase));

        stream.reset();
        while (steam.incrementToken())
            tokens.add(stream.getAttribute(CharTermAttribute).toString());
        stream.end();
        stream.close();

        return tokens;
    }
Use a custom scorer

The results you get back from a query and their order are heavily influenced by a number of factors: the text you have in your index, how you've tokenized and stored the text in the different fields of your index, and how you structure the query itself. You can also influence the ordering of results to a lesser degree by using a custom Similarity class when you build your index.

Lucene's default similarity class uses some fancy math to score the terms in its index (see this Lucene Scoring tutorial for a simpler explanation of the scoring algorithm), and you'll probably want to tweak only one or two of those factors. We implemented our own custom Similarity class that completely ignores document length, and provides a bigger boost for infrequently-appearing terms:

import org.apache.lucene.index.FieldInvertState;
import org.apache.lucene.search.similarities.DefaultSimilarity;

public class CustomSimilarity extends DefaultSimilarity {

    @Override
    public float lengthNorm(FieldInvertState state) {
        // simply return the field's configured boost value
        // instead of also factoring in the field's length
        return state.getBoost();
    }

    @Override
    public float idf(long docFreq, long numDocs) {
        // more-heavily weight terms that appear infrequently
        return (float) (Math.sqrt(numDocs/(double)(docFreq+1)) + 1.0);
    }
}

Once implemented, you can use this CustomSimilarity class when indexing by setting it on the IndexWriterConfig that you use for writing to the index, like this:

import java.io.File;
import org.apache.lucene.analysis.en.EnglishAnalyzer;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.util.Version;

...

    void indexSomething() {
        EnglishAnalyzer analyzer = new EnglishAnalyzer(Version.LUCENE_43);
        IndexWriterConfig config = new IndexWriterConfig(Version.LUCENE_43, analyzer);
        config.setSimilarity(new CustomSimilarity());

        FSDirectory directory = FSDirectory.open(new File("my-index"));
        IndexWriter writer = new IndexWriter(directory, config);
        // ... index something ...
        writer.close();
    }
Build your own query

Probably the single biggest way we improved our "result relevancy" in the eyes of our users was to build our queries programmatically from a user's query input, rather than asking them to use Lucene's standard query syntax. Our algorithm for generating queries first expands any abbreviations in the query (not using Lucene, just using an in-memory hashtable of our own custom list of abbreviations); then it builds a big query consisting of:

  1. the exact query phrase (with a little slop), boosted heavily
  2. varying combinations of the terms in the query phrase, boosted according to the number of matching terms
  3. individual terms in individual fields (using the boost associated with those fields)
  4. individual terms with no boost

This querying strategy compliments our indexing strategy, which is to index a few important fields of each document separately (like "name", "keywords", etc) with boost added to those fields at index time; and then to index all the text related to each document in on big fat field (the "all" field) with no boost associated with it. The parts of the query that check for different terms appearing in the same document (#1 and #2 from the list above) rely on the "all" field; whereas the parts of the query that check in which fields the terms appear (#3 and #4) make use of the other, specially-boosted fields.

Doing it this way allows us to instruct Lucene to weight results that contain more matches of different terms (or the exact phrase) more heavily than results that simply match the same term many times; but also to weight matches in important fields (like "name" and "keywords") above matches from the general text of the document.

The actual query-building part of our code looks like this (I removed the abbreviation-expanding bits for simplicity, though). The fields argument is the list of custom fields to query; the defaultField argument is the name of the "all" field; and it uses the tokenizePhrase() method from above to split the phrase into individual words:

import java.lang.Math;
import java.util.List;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.BooleanClause;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.PhraseQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;

...

    Query buildQuery(String phrase, List<String> fields, String defaultField) {
        List<String> words = tokenizePhrase(String phrase);
        BooleanQuery q = new BooleanQuery();

        // create term combinations if there are multiple words in the query
        if (words.size() > 1) {
            // exact-phrase query
            PhraseQuery phraseQ = new PhraseQuery();
            for (int w = 0; w < tokens.size(); w++)
                phraseQ.add(new Term(defaultField, words.get(w)));
            phraseQ.setBoost(words.size() * 5);
            phraseQ.setSlop(2);
            q.add(phraseQ, BooleanClause.Occur.SHOULD);

            // 2 out of 4, 3 out of 4, 4 out of 4 (any order), etc
            // stop at 7 in case user enters a pathologically long query
            int maxRequired = Math.min(tokens.size(), 7);
            for (int minRequired = 2; minRequired <= maxRequired; minRequired++) { 
                BooleanQuery comboQ = new BooleanQuery();
                for (int w = 0; w < tokens.size(); w++)
                    comboQ.add(new Term(defaultField, words.get(w)), BooleanClause.Occur.SHOULD);
                comboQ.setBoost(minRequired * 3);
                comboQ.setMinimumNumberShouldMatch(minRequired);
                q.add(comboQ, BooleanClause.Occur.SHOULD);
            }
        }

        // create an individual term query for each word for each field
        for (int w = 0; w < tokens.size(); w++)
            for (int f = 0; f < fields.size(); f++)
                q.add(new Term(fields.get(f), words.get(w)), BooleanClause.Occur.SHOULD);

        return q;
    }
Boost important fields when indexing

When we do the document indexing, we set the boost of some of the important fields (like "name" and "keywords", etc), as described above, while dumping all the document's text (including name and keywords) into the "all" field. Following is an example (in which we use our own customized FieldType so that we can configure the field with the IndexOptions that the result highlighter needs, discussed later). The toDocument() method translates some particular type of domain object to a Lucene Document, with appropriate "kewords", "name", "all", etc fields; it would be called by our indexing process (from the indexSomething() method above) for each instance of that domain type that we have in our system in order to create a separate document with which to index each domain:

import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.FieldType;
import org.apache.lucene.index.FieldInfo;

...

    protected static final FieldType TEXT_FIELD_TYPE = getTextFieldType();

    static FieldType getTextFieldType() {
        FieldType type = new FieldType();
        type.setIndexed(true);
        type.setIndexOptions(FieldInfo.IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS);
        type.setStored(true);
        type.setTokenized(true);
        return type;
    }

    Document toDocument(MyDomainObject domain) {
        Document doc = new Document();

        Field keywordsField = new Field("keywords", domain.keywords, TEXT_FIELD_TYPE);
        keywordsField.setBoost(3f);
        doc.add(keywordsField);

        Field nameField = new Field("name", domain.name, TEXT_FIELD_TYPE);
        nameField.setBoost(2f);
        doc.add(nameField);

        // ... other fields ...

        StringBuilder all = new StringBuilder().
            append(domain.kewords).append("\n").
            append(domain.name).append("\n").
            append(domain.text).append("\n").
            append(domain.moreText).append("\n").
            toString();
        Field allField = new Field("all", all, TEXT_FIELD_TYPE);
        doc.add(allField);

        return doc;
    }
Filter by date with a NumericRangeQuery

Many of our individual documents are relevant only during a short time period, with the exact start and end dates defined by the document. When we query for anything, we query against a specific day chosen by the user. In our Lucene searches, we implement this with a filter that wraps a pair of NumericRangeQuerys, querying the "startDate" and "endDate" fields (although a more common scenario in other applications, however, might be to have a single "publishedDate" for each document, and allow users to choose separate start and end dates against which to filter -- in that case, you'd use a single NumericRangeQuery). We index the "startDate" and "endDate" fields like this, using an integer field of the form 20010203 to represent a date like 2001-02-03 (Feb 3, 2001):

import org.apache.lucene.document.Document;
import org.apache.lucene.document.IntField;
import org.apache.lucene.document.FieldType;
import org.apache.lucene.index.FieldInfo;

...

    protected static final FieldType DATE_FIELD_TYPE = new FieldType();
    static {
        TEXT_FIELD_TYPE.setIndexed(true);
        TEXT_FIELD_TYPE.setIndexOptions(FieldInfo.IndexOptions.DOCS_ONLY);
        TEXT_FIELD_TYPE.setNumericType(FieldType.NumericType.INT);
        TEXT_FIELD_TYPE.setOmitNorms(true);
        TEXT_FIELD_TYPE.setStored(true);
    }

    Document toDocument(MyDomainObject domain) {
        Document doc = new Document();

        Field startField = new Field("startDate", domain.startDate, DATE_FIELD_TYPE);
        doc.add(startField);

        Field endField = new Field("endDate", domain.endDate, DATE_FIELD_TYPE);
        doc.add(endField);

        // ... other fields ...

        return doc;
    }

Then we build a filter like this, caching a separate filter instance per date (dates again represented in integer form like 20010203 to stand for 2001-02-03):

import org.apache.lucene.search.BooleanClause;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.Filter;
import org.apache.lucene.search.NumericRangeQuery;
import org.apache.lucene.search.QueryWrapperFilter;

...

    synchronized protected Map cachedFilters = new HashMap();

    Filter getDateFilter(int date) {
        Filter filter = cachedFilters.get(date);

        if (filter == null) {
            BooleanQuery q = new BooleanQuery();

            // startDate must be on or before the specified date
            q.add(NumericRangeQuery.newIntRange(
                "startDate", 0, date, true, true
            ), BooleanClause.Occur.MUST);

            // endDate must be on or after the specified date
            // 30000000 represents the distant future (just prior to the year 3000)
            add NumericRangeQuery.newIntRange(
                "endDate", date, 30000000, true, true
            ), BooleanClause.Occur.MUST);

            filter = new QueryWrapperFilter(q);
            cachedFilters.put(date, filter);
        }

        return filter
    }
Use a SearcherManager for multi-threaded searching

To manage the access of multiple threads searching the index, Lucene provides a simple SearchManager class. Once the index has been created, you can instantiate it and call its acquire() method to check out a IndexSearcher instance.

We needed to initialize our IndexSearcher instances with our custom Similarity class (discussed above), so we initialized the manager with a custom SearcherFactory, which then allowed us to customize the IndexSearcher initialization process:

import org.apache.lucene.index.IndexReader;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.SearcherFactory;

public class CustomSearcherFactory extends SearcherFactory {

    @Override
    public IndexSearcher newSearcher(IndexReader r) throws IOException {
        IndexSearcher searcher = new IndexSearcher(r);
        searcher.setSimilarity(new CustomSimilarity());
        return searcher;
    }
}

To use it, we create a SearcherManager instance when initializing the index (in the init() method) — note that the index must already exist before creating the SearcherManager; and then acquire and release the IndexSearcher it provides whenever we actually need to run a search on the index (in the search() method):

import java.io.File;
import org.apache.lucene.search.Filter;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.SearcherManager;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.store.FSDirectory;

...

    protected SearcherManager searchManager;

    protected init() {
        FSDirectory directory = FSDirectory.open(new File("my-index"));
        searchManager = new SearcherManager(directory, new CustomSearcherFactory());
    }

    public TopDocs search(Query query, Filter filter, int maxResults) {
        IndexSearcher searcher = searchManager.acquire();
        try {
            return searcher.search(query, filter, maxResults);
        } finally {
            searchManager.release(searcher);
        }
    }

After re-indexing, make sure to call maybeRefresh() on the SearchManager to refresh the managed IndexSearchers with the latest copy of the index. In other words, indexSomething() method from above would be finished like this:

    void indexSomething() {
        // ... index something ...
        writer.close();

        searchManager.maybeRefresh();
    }
Highlight results with a PostingsHighlighter

The PostingsHighlighter class is the newest implementation of a results highlighter for Lucene (the component that comes up with the fragments of text to display for each result in the search-results UI). It's only been part of Lucene since the 4.1 release, but our experience has been that it selects more-clearly relevant sections of the text than the previous highlighter implementation, the FastVectorHighlighter.

The first step to using a results highlighter is to make sure that you include at index time the data that the highlighter will need at search time. With the FastVectorHighlighter, we used this configuration for a regular indexed field:

import org.apache.lucene.document.FieldType;
import org.apache.lucene.index.FieldInfo;

...

    static FieldType getTextFieldType() {
        FieldType type = new FieldType();
        type.setIndexed(true);
        type.setIndexOptions(FieldInfo.IndexOptions.DOCS_AND_FREQS_AND_POSITIONS);
        type.setStored(true);
        type.setStoredTermVectorOffsets(true);
        type.setStoredTermVectorPayloads(true);
        type.setStoredTermVectorPositions(true);
        type.setStoredTermVectors(true);
        type.setTokenized(true);
        return type;
    }

But with the PostingsHighlighter, we found we didn't need to store the term vectors anymore — but we did need to index the term offsets:

import org.apache.lucene.document.FieldType;
import org.apache.lucene.index.FieldInfo;

...

    static FieldType getTextFieldType() {
        FieldType type = new FieldType();
        type.setIndexed(true);
        type.setIndexOptions(FieldInfo.IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS);
        type.setStored(true);
        type.setTokenized(true);
        return type;
    }

The PostingsHighlighter, by default, selects complete sentences to show. We have a lot of text that isn't in the form of proper sentences, however (much of our text isn't in the form of sentences begun with a captial letter and completed with a period and whitespace), so we subclassed the PostingsHighlighter with a class that uses a custom BreakIterator implementation that selects just a few words around each term to display.

With or without a custom BreakIterator, it's easy to use the PostingsHighlighter. You do need to have the IndexSearcher and TopDocs instance from the initial search results to use the PostingsHighlighter, so you might as well do both the search and the highlighting in the same method, returning the combined results in some intermediate data structure. For example, we can use a custom inner class called Result for each individual result, and combine one Lucene document object from the search results with the corresponding highlights text string from the highlighter in each returned Result:

import java.util.ArrayList;
import java.util.List;
import org.apache.lucene.document.Document;
import org.apache.lucene.search.Filter;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.search.postingshighlight.PostingsHighlighter;

...

    public class Result {
        public Document document;
        public String highlights;

        public Result(Document document, String highlights) {
            this.document = document;
            this.highlights = highlights;
        }
    }

    protected PostingsHighlighter highlighter = new PostingsHighlighter();

    public List<Result> search(Query query, Filter filter, int maxResults) {
        IndexSearcher searcher = searchManager.acquire();
        try {
            TopDocs topDocs = searcher.search(query, filter, maxResults);
            // select up to the three best highlights from the "all" field
            // of each result, concatenated with ellipses
            String[] highlights = highlighter.highlight("all", query, searcher, topDocs, 3);

            int length = topDocs.scoreDocs.length;
            List<Result> results = new ArrayList<Result>(length);
            for (int i = 0; i < length; i++) {
                int docId = topDocs.scoreDocs[i].doc;
                results.add(new Result(searcher.doc(docId), highlights[i]));
            }
            return results;

        } finally {
            searchManager.release(searcher);
        }
    }
With a tree, index leaves only

Some of our data is in hierarchical form, and we display the search results for that data in tree from. Rather than indexing all the nodes in the tree, however, we just index the leaves, and make sure that each leaf also includes the relevant text from its ancestors.

We also include the necessary info to render the leaf's branch as a separate, non-indexed "hierarchy" field in each leaf. When the leaf is returned as a search result, we build the branch out of that "hierarchy" field, and then merge the branches together to show each leaf in the context of the full tree.

This is the field configuration we use for the non-indexed "hierarchy" field:

import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.FieldType;
import org.apache.lucene.index.FieldInfo;

...

    protected static final FieldType NON_INDEXED_FIELD_TYPE = getNonIndexedFieldType();

    static FieldType getNonIndexedFieldType() {
        FieldType type = new FieldType();
        type.setIndexed(false);
        type.setOmitNorms(true);
        type.setStored(false);
        return type;
    }

    Document toDocument(MyDomainObject domain) {
        Document doc = new Document();

        // ... other fields ...

        String hierarchy = domain.getHierarchyText();
        Field allField = new Field("hierarchy", hierarchy, NON_INDEXED_FIELD_TYPE);
        doc.add(allField);

        return doc;
    }
Use a SpellChecker for auto-complete suggestions

For auto-complete suggestions in our application's search box, we created a custom search index of common words in our application domain that were at least six letters long, and used Lucene's SpellChecker class to index and search this word list. We skipped words less than six letters long to avoid suggesting simple words when the user has typed in only the first few letters of a word. To build the index, we created a plain text file with one word on each line, and indexed it with the following indexDictionary() method:

import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.PrefixQuery;
import org.apache.lucene.search.SearcherFactory;
import org.apache.lucene.search.SearcherManager;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.search.spell.PlainTextDictionary;
import org.apache.lucene.search.spell.SpellChecker;
import org.apache.lucene.search.spell.SuggestMode;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.util.Version;

public class Suggestor {

    File directory = new File("suggestion-index");
    SpellChecker spellChecker = new SpellChecker(FSDirectory.open(directory));

    public void indexDictionary(File dictionaryFile) {
        PlainTextDictionary dictionary = new PlainTextDictionary(dictionaryFile);
        IndexWriterConfig config = new IndexWriterConfig(Version.LUCENE_43,
            new StandardAnalyzer(Version.LUCENE_43));
        spellChecker.indexDictionary(dictionary, config, true);
    }
}

To then search it, we used a simple PrefxQuery for (partial) words less than 5 letters long; for longer words we used the SpellChecker's built-in fuzzy suggestion algorithm (with a 0.2f factor to make it even more fuzzy than the default). The suggestSimilar() method of our Suggestor class will return a list of up to 10 words appropriate as auto-completions for the partial word specified in the argument to suggestSimilar(). It delegates to helper prefixSearch() and fuzzySuggest() methods to actually run the search based on the length of the specified partial word:

    protected SearcherManager manager;

    protected getSearcherManager() {
        synchronized (directory) {
            if (manager == null)
                manager = new SearcherManager(
                    FSDirectory.open(directory), new SearcherFactory());
            return manager;
        }
    }

    public List<String> suggestSimilar(String s) {
        // search with prefix query if less than 5 chars
        // otherwise use spellChecker's built-in fuzzy suggestions
        return s.length() < 5 ? prefixSearch(s) : fuzzySuggest(s);
    }

    protected List<String> prefixSearch(String s) {
        SuggestionManager manager = getSearcherManager();
        IndexSearcher searcher = manager.acquire();
        try {
            // search for the top 10 words starting with s
            Term term = new Term("word", s.toLowerCase())
            TopDocs topDocs = searcher.search(new PrefixQuery(term), 10);

            int length = topDocs.scoreDocs.length;
            List<String> results = new ArrayList<String>(length);
            for (int i = 0; i < length; i++) {
                int docId = topDocs.scoreDocs[i].doc;
                results.add((searcher.doc(docId).get("word"));
            }
            return results;
        } finally {
            manager.release(searcher);
        }
    }

    protected List<String> fuzzySuggest(String s) {
        // search for 10 most popular words not exactly matching s
        String[] similar = spellChecker.suggestSimilar(
            s.toLowerCase(), 10, null, null,
            SuggestMode.SUGGEST_MORE_POPULAR, 0.2f);
        List<String> results = Arrays.asList(similar);

        // include queried term if it is itself a recognized word
        if (spellChecker.exist(term) {
            if (results.isEmpty())
                results.append(term);
            else
                results.set(results.size() - 1, term);
        }

        return results;
    }

Sunday, September 9, 2012

EC2 Reserved Instance "Break Even" Points

Updated July 21, 2013 with the latest EC2 and RDS prices:

While trying to figure out the best deal for some EC2 instances we have running continuously (and inspired by Jordan Sissel's EC2 Reserved vs. On-Demand Chart), I charted the EC2 pricing curves for the various reserve options (light/medium/heavy utilization and 1- or 3-year terms). With reserve pricing, you're basically buying the right to run a specific instance type at a reduced hourly rate for a 1-year or 3-year term. For example, if you buy one "light-utilization" 1-year term for a small EC2 instance in your us-east-1a availability-zone, you pay $61 dollars now; and whenever you run at least one small instance in us-east-1a during the next 365 days, Amazon will automatically apply the reduced hourly rate to your usage for one of those instances (billing you at $0.034 an hour instead of $0.060 an hour for the instance).

Note that the "heavy" plans are a little bit different than the "light" and "medium" plans — Amazon will bill you each month for the full compliment of hours under the "heavy" plan, regardless of whether or not you actually use all the hours. In contrast, with the "light" and "medium" plans, Amazon will bill you only for the hours during which you had an applicable instance running. For example, if you've bought a "light" 1 year plan, and run an applicable instance for 100 hours in a 30-day month, Amazon will bill you $3.40 ($0.034 x 100 hours). But if you've bought a "heavy" 1 year plan, and run the same instance the same 100 hours, Amazon will bill you $10.08 ($0.014 x 24 hours x 30 days) instead.

Amazon's characterization of their reserve options as "light utilization", "medium utilization", and "heavy utilization" is also confusing, since you can use a "light utilization" plan heavily, and a "heavy utilization" plan lightly — you just have to think of the names as suggestions for how the plans might be most useful to you, rather than as restrictions on their use. And when you're using a number of instances of a certain type all the time, what really matters is how long you expect to continue to use that same number of instances in the future: one month, two months, a year, etc.

The Chart

So this is how I use the chart I made (although it shows the "small-instance" costs for the us-east-1 zone, all the EC2 reserve pricing follows the same basic curves): I figure out how likely we are to continue using an instance type (either in its current role, or in some future role) for which we're currently paying on-demand prices, try to put that likelihood into months (like "there's a good chance I'll still be using it in 2 months, and probably will be in 6 months, but I might not be in a year"), and then take a look at where those months fall on the chart:

EC2 price points

The X axis is months, and the Y axis is dollars. Here's the legend:

Option NameTerm Years$ Upfront$ Hourly
On Demand0000.000.060
Light 1yr1061.000.034
Light 3yr3096.000.027
Medium 1yr1139.000.021
Medium 3yr3215.000.017
Heavy 1yr1169.000.014
Heavy 3yr3257.000.012

You can view the full chart separately (it's a javascript chart with fancy tooltips built using elycharts). The chart shows the cumulative cost after each month; for example, the first light-green dot shows that after one month of running a small instance 24/7, you'll have spent a total of $85.82 under the light-utilization 1-year term plan (from the upfront cost of $61, plus the hourly cost of $0.034 for one month). I modeled the "heavy" options as having a fixed price over their term — while technically their price isn't all upfront (Amazon bills you for the hourly component of those plans month-by-month), you'll eventually have to pay for all their hours, regardless of whether you use them or not (so your total cost is the same whether you use 1 hour or 10,000).

The Bottom Line

The chart shows that your best deal for an instance that you use 24/7 is "on demand" if you use it less than three months — but if you use it any longer, you would have been better off buying a "light utilization" plan. Here's a table of the best deal for a each length of time:

MonthsBest Option
00 - 03On Demand
03 - 07Light 1yr
07 - 10Light 3yr
10 - 12Heavy 1yr
12 - 16Light 3yr
16 - 29Medium 3yr
29 - 36Heavy 3yr

As an even rougher rule of thumb: less than half-a-year, "on demand"; half-a-year to a year-and-a-half, "light 3yr"; and over a year-and-a-half, "medium 3yr".

If you do actually use the heavy-utilization 3-year term plan for the full three years, it's a nice deal that can save you almost two thirds of what you'd pay for "on demand" over that time period (and not much more than what you'd pay a low-end VPS-hosting provider for a VPS with similar specs over the same time period) — but if you use it for less than half that time, you'll wish you had chosen any other option. Also, keep in mind that each reserved instance you buy is applicable only to a specific instance-type in a specific availability-zone — so if halfway through your term you stop using so many instances of that type, or move your usage of those instances to a different region, you'll be stuck with reserved instances you never use.

Other AWS Reserved Instances

I also included a chart for RDS instances on the same page as the full chart of EC2 instance pricing (below the EC2 chart). It looks quite similar to the EC2 chart, but if you look closely you'll see that the "break-even" points are slightly different: "light 3yr" becomes a better option than "on demand" around 3 months instead of 4, and as good as "light 1yr" in just 4 months; and "medium 3yr" becomes the best deal at just 14 months. I imagine the reserve options for other AWS services will be similar — same curves, slightly different deals.

Sunday, June 3, 2012

Using /etc/mime.types for Grails Files Downloads

I find it sorely disappointing that ServletContext#getMimeType() doesn't just use apache's standard /etc/mime.types automatically. Instead, it seems to use some smaller subset of extension mappings included in some servlet jar somewhere. Fortunately, it's easy enough to use spring's ConfigurableMimeFileTypeMap bean to get the mappings from the mime.types files of your choosing.

The way I like to set it up is to add a custom configuration property in my grails-app/conf/Config.groovy, and specify a comma-separated list of file paths to use (that way I can use the standard /etc/mime.types file, but also add in my own custom mime.types file if I need some mappings not in the standard one):

grails-app/conf/Config.groovy
mime.types = '/etc/mime.types'

Then, in grails-app/conf/spring/resources.groovy, I define a ConfigurableMimeFileTypeMap bean (I call it fileTypeMap, but you can call it whatever):

grails-app/conf/resources.groovy
fileTypeMap(org.springframework.mail.javamail.ConfigurableMimeFileTypeMap) { application.config.mime.types.split(/,/).each { mappingLocation = new org.springframework.core.io.FileSystemResource(it) } }

Finally, in the controller actions where I want to stream out a file, I use the ConfigurableMimeFileTypeMap bean's getContentType() method to calculate the file's content type from its name:

grails-app/controllers/MyController.groovy
def fileTypeMap def download = { try { // some logic here to locate the appropriate file to stream def file = new File('/tmp/foo.txt') // use /etc/mime.types to determine the file's content type from its extension def contentType = fileTypeMap.getContentType(file.name.toLowerCase()) // must special-case text/html; see http://jira.grails.org/browse/GRAILS-1223 if (contentType == 'text/html') return render(contentType: contentType, text: file.text) // set up standard (inline) file-download headers response.contentType = contentType response.contentLength = file.length() as Integer response.setHeader 'Content-Disposition', "inline; filename=\"${file.name}\"" // stream the file file.inputStream.withStream { response.outputStream << it } } catch (FileNotFoundException e) { response.sendError 404 } }

Sunday, April 15, 2012

Fastest Ext4 Options

When I got a new external hard drive for storing mp3s, recorded TV shows, etc, I wanted to know how to set it up most optimally (using the ext4 filesystem). I ended up following Luca Spiller's Ext4 Options for a Media Drive for the most part, but with a few tweaks:

Formatting the Drive

After plugging in the hard drive and running sudo fdisk -l to check what name the OS had assigned it (/dev/sdb1), I formatted the drive with the following options: -m 0 to create no extra room for root (I don't intend to use it as a boot drive); and -L bb to assign it a label of bb (so I can reference it by label in my /etc/fstab):

sudo mkfs.ext4 -m 0 -L bb /dev/sdb1
Configuring the Drive Options

Then I updated my /etc/fstab configuration with an entry for the new drive. Beyond the filesystem permission options of user, rw, exec, and suid (the order of which is significant), and the noauto option (to ignore the drive when booting), I added some options that make writing data less safe — but faster. Use man mount to get a brief description of these and all other possible options:

LABEL=bb /mnt/bb ext4 user,rw,exec,suid,noauto,noatime,nobh,nobarrier,commit=60,data=writeback,journal_async_commit 0 0

If this was an internal hard drive, or one that I intended to have connected at all times, I would have skipped the permissions and noauto options (so as to use the default permissions and allow it to auto-mount at boot time), and would have just specified the performance options:

LABEL=bb /mnt/bb ext4 noatime,nobh,nobarrier,commit=60,data=writeback,journal_async_commit 0 2
Creating the Drive Mount Point

In /etc/fstab I had configured the drive's mount point as /mnt/bb, so I created it and set its owner to myself (so I could mount the drive as a regular user, since I had included the user option in the /etc/fstab config):

sudo mkdir /mnt/bb && sudo chown justin:justin /mnt/bb
Mounting the Drive

Now whenever I plug in the drive, I can mount it as a regular user; either via its label:

mount -L bb

Or via its mount point:

mount /mnt/bb

Jetty 6 HTTPS Redirects

Another Jetty 6 trick I have had to use several times is patching it to enable redirects with the correct URL scheme when proxyied via HTTP behind a webserver using SSL. Because the connection between the webserver and jetty is just plain HTTP (without SSL), jetty will send the redirect with a plain http scheme:

Location: http://example.com/foo

But when the webserver is using SSL, what I really want is for it to send the redirect with an https scheme:

Location: https://example.com/foo

X-Forwarded- Headers

As described in jetty's reverse proxy docs, by setting the forwarded property in jetty's connector configuration, you can get jetty to use the server name/port and remote client IP-address from the X-Forwarded-Host and X-Forwarded-For headers that apache's mod_proxy includes automatically.

One header that mod_proxy does not include automatically, however, is X-Forwarded-Proto. You have to add that manually, via the RequestHeader directive in your apache config (wherever you included the ProxyPass directive that forwards requests to jetty):

ProxyPass http://localhost:8080/ ProxyPassReverse http://localhost:8080/ RequestHeader set X-Forwarded-Proto "https"

Patching Jetty 6

For jetty 7/8, that would be sufficient. With the above configuration, they'll send the correct redirects. But jetty 6 doesn't use the X-Forwarded-Proto header, so you have to create your own connector class to handle it. This is what I've done (for the nio connectors):

package com.pitchstone.lib.jetty; import java.io.IOException; import org.mortbay.io.EndPoint; import org.mortbay.jetty.HttpFields; import org.mortbay.jetty.Request; import org.mortbay.jetty.nio.SelectChannelConnector; /** * Jetty nio connector. * Adds the ability to use the 'X-Forwarded-Proto' header * to set the request 'scheme' property. */ public class NioConnector extends SelectChannelConnector { private String _forwardedProtoHeader = "X-Forwarded-Proto"; public NioConnector() { super(); } // AbstractConnector protected void checkForwardedHeaders(EndPoint endpoint, Request request) throws IOException { super.checkForwardedHeaders(endpoint, request); HttpFields httpFields = request.getConnection().getRequestFields(); String forwardedProto = httpFields.getStringField(getForwardedProtoHeader()); forwardedProto = getLeftMostValue(forwardedProto); if ("http".equals(forwardedProto) || "https".equals(forwardedProto)) request.setScheme(forwardedProto); } // impl public String getForwardedProtoHeader() { return _forwardedProtoHeader; } public void setForwardedProtoHeader(String x) { _forwardedProtoHeader = x; } }

To use it, compile it, jar it up, and add the jar to jetty's lib/ext directory (/usr/share/jetty/lib/ext by default under ubuntu/debian). Then configure jetty.xml to use it, replacing org.mortbay.jetty.nio.SelectChannelConnector with this custom version:

<Call name="addConnector"> <Arg> <New class="org.mortbay.jetty.nio.SelectChannelConnector"> <New class="com.pitchstone.lib.jetty.NioConnector"> <Set name="host"><SystemProperty name="jetty.host" /></Set> ... <Set name="forwarded">true</Set> </New> </Arg> </Call>

With that configuration and patch in place, jetty will now send redirects with the https scheme. It also will map these X-Forwarded-For headers to the ServletRequest API like so:

HeaderServletRequest Method
X-Forwarded-HostgetServerName()
X-Forwarded-HostgetServerPort()
X-Forwarded-ForgetRemoteAddr()
X-Forwarded-ForgetRemoteHost()
X-Forwarded-ProtogetScheme()

Thursday, January 5, 2012

Log4j + Jetty 6 + Fedora

Since this is the second time I've had to setup a (Grails) app that uses Log4j with the version of Jetty 6 that comes with Fedora (specifically Fedora 14), I figured I'd document how I had to change the jetty configuration to get the app working right:

Add jsp 2.1 to jetty's lib

Download the latest Jetty 6 release (directly from the Codehaus), and copy its lib/jsp-2.1 directory into the /usr/share/jetty/lib directory you get with Fedora (so that you have a new /usr/share/jetty/lib/jsp-2.1 directory to go along with the existing /usr/share/jetty/lib/jsp-2.0 directory). This should solve your SLF4J problems.

Remove commons-logging.jar from jetty's classpath

The Fedora jetty daemon ultimately runs the /usr/bin/djetty script to start jetty. Edit it to remove the Commons Logging jar from the classpath, and to allow additional java options:

#!/bin/bash if [ -z "$JAVA_OPTIONS" ] then export JAVA_OPTIONS="-Xmx1500m -XX:MaxPermSize=500m" fi if [ -z "$JETTY_CLASSPATH" ] then export JETTY_CLASSPATH="" fi if [ -z "$JETTY_PID" ] then export JETTY_PID=/dev/null fi if [ -z "$JETTY_PORT" ] then export JETTY_PORT=8088 fi export JETTY_HOME=/usr/share/jetty if [ -z "$JETTY_HOME" ] then JETTY_HOME_1=`dirname "$0"` JETTY_HOME_1=`dirname "$JETTY_HOME_1"` JETTY_HOME=${JETTY_HOME_1} fi cd $JETTY_HOME #exec /usr/bin/java -Djetty.class.path=/usr/share/java/commons-logging.jar -Djetty.port=$JETTY_PORT -jar start.jar etc/jetty-logging.xml etc/jetty.xml 2>/dev/null & exec /usr/bin/java -Djetty.class.path="$JETTY_CLASSPATH" -Djetty.port=$JETTY_PORT $JAVA_OPTIONS -jar start.jar etc/jetty-logging.xml etc/jetty.xml 2>/dev/null & echo $! >$JETTY_PID

Now grails' Log4j configuration should take effect.

Remove jetty's javamail.jar

Since the grails apps I've deployed included javamail jars in their own war, I get rid of the /usr/share/jetty/lib/naming/[javamail].jar (symlink) — otherwise sending mail via SMTP just fails silently.

Remove jetty's sample apps

Delete the sample apps in /usr/share/jetty/contexts and /usr/share/jetty/webapps. There's no reason to keep them, and I usually want my apps to use the root context path in their place anyway.