# -*- test-case-name: xmantissa.test.test_fulltext -*-
"""
General functionality re-usable by various concrete fulltext indexing systems.
"""
import atexit, os, weakref
from zope.interface import implements
from twisted.python import log, reflect
from twisted.internet import defer
from epsilon.structlike import record
from axiom import item, attributes, iaxiom, batch
from axiom.upgrade import registerUpgrader, registerAttributeCopyingUpgrader
from xmantissa import ixmantissa
HYPE_INDEX_DIR = u'hype.index'
XAPIAN_INDEX_DIR = u'xap.index'
LUCENE_INDEX_DIR = u'lucene.index'
VERBOSE = True
class IndexCorrupt(Exception):
"""
An attempt was made to open an index which has had unrecoverable data
corruption.
"""
class _IndexerInputSource(item.Item):
"""
Tracks L{IBatchProcessor}s which have had an indexer added to them as a
listener.
"""
indexer = attributes.reference(doc="""
The indexer item with which this input source is associated.
""", whenDeleted=attributes.reference.CASCADE)
source = attributes.reference(doc="""
The L{IBatchProcessor} which acts as the input source.
""", whenDeleted=attributes.reference.CASCADE)
class _RemoveDocument(item.Item):
"""
Tracks a document deletion which should occur before the next search is
performed.
"""
indexer = attributes.reference(doc="""
The indexer item with which this deletion is associated.
""", whenDeleted=attributes.reference.CASCADE)
documentIdentifier = attributes.bytes(doc="""
The identifier, as returned by L{IFulltextIndexable.uniqueIdentifier},
for the document which should be removed from the index.
""", allowNone=False)
class RemoteIndexer(object):
"""
Implements most of a full-text indexer.
This uses L{axiom.batch} to perform indexing out of process and presents an
asynchronous interface to in-process searching of that indexing.
"""
implements(iaxiom.IReliableListener, ixmantissa.ISearchProvider, ixmantissa.IFulltextIndexer)
def installOn(self, other):
super(RemoteIndexer, self).installOn(other)
other.powerUp(self, ixmantissa.IFulltextIndexer)
def openReadIndex(self):
"""
Return an object usable to search this index.
Subclasses should implement this.
"""
raise NotImplementedError
def openWriteIndex(self):
"""
Return an object usable to add documents to this index.
Subclasses should implement this.
"""
raise NotImplementedError
def __finalizer__(self):
d = self.__dict__
id = self.storeID
s = self.store
def finalize():
idx = d.get('_index', None)
if idx is not None:
if VERBOSE:
log.msg("Closing %r from finalizer of %s/%d" % (idx, s, id))
idx.close()
return finalize
def activate(self):
assert not hasattr(self, '_index')
self._index = None
if VERBOSE:
log.msg("Activating %s/%d with null index" % (self.store, self.storeID))
def addSource(self, itemSource):
"""
Add the given L{IBatchProcessor} as a source of input for this indexer.
"""
_IndexerInputSource(store=self.store, indexer=self, source=itemSource)
itemSource.addReliableListener(self, style=iaxiom.REMOTE)
def getSources(self):
return self.store.query(_IndexerInputSource, _IndexerInputSource.indexer == self).getColumn("source")
def reset(self):
"""
Process everything all over again.
"""
self.indexCount = 0
indexDir = self.store.newDirectory(self.indexDirectory)
if indexDir.exists():
indexDir.remove()
for src in self.getSources():
src.removeReliableListener(self)
src.addReliableListener(self, style=iaxiom.REMOTE)
def _closeIndex(self):
if VERBOSE:
log.msg("%s/%d closing index" % (self.store, self.storeID))
if self._index is not None:
if VERBOSE:
log.msg("%s/%d *really* closing index" % (self.store, self.storeID))
self._index.close()
self._index = None
# IFulltextIndexer
def add(self, item):
if self._index is None:
try:
self._index = self.openWriteIndex()
except IndexCorrupt:
self.reset()
return
if VERBOSE:
log.msg("Opened %s %s/%d for writing" % (self._index, self.store, self.storeID))
if VERBOSE:
log.msg("%s/%d indexing document" % (self.store, self.storeID))
self._index.add(ixmantissa.IFulltextIndexable(item))
self.indexCount += 1
def remove(self, item):
identifier = ixmantissa.IFulltextIndexable(item).uniqueIdentifier()
if VERBOSE:
log.msg("%s/%d scheduling %r for removal." % (self.store, self.storeID, identifier))
_RemoveDocument(store=self.store,
indexer=self,
documentIdentifier=identifier)
def _flush(self):
"""
Deal with pending result-affecting things.
This should always be called before issuing a search.
"""
remove = self.store.query(_RemoveDocument)
documentIdentifiers = list(remove.getColumn("documentIdentifier"))
if VERBOSE:
log.msg("%s/%d removing %r" % (self.store, self.storeID, documentIdentifiers))
reader = self.openReadIndex()
map(reader.remove, documentIdentifiers)
reader.close()
remove.deleteFromStore()
# IReliableListener
def suspend(self):
self._flush() # Make sure any pending deletes are processed.
if VERBOSE:
log.msg("%s/%d suspending" % (self.store, self.storeID))
self._closeIndex()
return defer.succeed(None)
def resume(self):
if VERBOSE:
log.msg("%s/%d resuming" % (self.store, self.storeID))
return defer.succeed(None)
def processItem(self, item):
return self.add(item)
# ISearchProvider
def search(self, aString, keywords=None, count=None, offset=0, sortAscending=True, retry=3):
ident = "%s/%d" % (self.store, self.storeID)
b = iaxiom.IBatchService(self.store)
if VERBOSE:
log.msg("%s issuing suspend" % (ident,))
d = b.suspend(self.storeID)
def reallySearch(ign):
if VERBOSE:
log.msg("%s getting reader index" % (ident,))
idx = self.openReadIndex()
if VERBOSE:
log.msg("%s searching for %s" % (ident, aString))
results = idx.search(aString.encode('utf-8'), keywords, sortAscending)
if VERBOSE:
log.msg("%s found %d results" % (ident, len(results)))
if count is None:
end = None
else:
end = offset + count
results = results[offset:end]
if VERBOSE:
log.msg("%s sliced from %s to %s, leaving %d results" % (
ident, offset, end, len(results)))
return results
d.addCallback(reallySearch)
def resumeIndexing(results):
if VERBOSE:
log.msg("%s issuing resume" % (ident,))
b.resume(self.storeID).addErrback(log.err)
return results
d.addBoth(resumeIndexing)
def searchFailed(err):
log.msg("Search failed somehow:")
log.err(err)
if retry:
log.msg("Re-issuing search")
return self.search(aString, keywords, count, offset, retry=retry-1)
else:
log.msg("Wow, lots of failures searching. Giving up and "
"returning (probably wrong!) no results to user.")
return []
d.addErrback(searchFailed)
return d
try:
import hype
except ImportError:
hype = None
class _HypeIndex(object):
def __init__(self, index):
self.index = index
self.close = index.close
def add(self, message):
doc = hype.Document()
for (k, v) in message.valueParts():
doc.add_hidden_text(v.encode('utf-8'))
doc['@uri'] = message.uniqueIdentifier()
for part in message.textParts():
doc.add_text(part.encode('utf-8'))
self.index.put_doc(doc)
def search(self, term, keywords=None, sortAscending=True):
return [int(d.uri) for d in self.index.search(term)]
class HypeIndexer(RemoteIndexer, item.Item):
schemaVersion = 3
indexCount = attributes.integer(default=0)
installedOn = attributes.reference()
indexDirectory = attributes.text(default=HYPE_INDEX_DIR)
_index = attributes.inmemory()
if hype is None:
def openReadIndex(self):
raise NotImplementedError("hype is unavailable")
def openWriteIndex(self):
raise NotImplementedError("hype is unavailable")
else:
def openReadIndex(self):
hypedir = self.store.newDirectory(self.indexDirectory)
return _HypeIndex(hype.Database(hypedir.path, hype.ESTDBREADER | hype.ESTDBLCKNB | hype.ESTDBCREAT))
def openWriteIndex(self):
hypedir = self.store.newDirectory(self.indexDirectory)
return _HypeIndex(hype.Database(hypedir.path, hype.ESTDBWRITER | hype.ESTDBCREAT))
try:
import xapwrap.index, xapwrap.document
except ImportError:
xapwrap = None
class _XapianIndex(object):
def __init__(self, smartIndex):
self.smartIndex = smartIndex
self.close = smartIndex.close
def add(self, message):
textFields = []
for part in message.textParts():
textFields.append(xapwrap.document.TextField(part.encode('utf-8')))
values = [
xapwrap.document.Value(k, v.encode('utf-8'))
for (k, v)
in message.valueParts()
]
keywords = [
xapwrap.document.Keyword(k, v.encode('utf-8'))
for (k, v)
in message.keywordParts()]
self.smartIndex.index(
xapwrap.document.Document(textFields=textFields,
values=values,
keywords=keywords,
uid=message.uniqueIdentifier()))
def search(self, term, keywords=None, sortAscending=True):
return [d['uid'] for d in self.smartIndex.search(term.encode('utf-8'))]
class XapianIndexer(RemoteIndexer, item.Item):
schemaVersion = 3
indexCount = attributes.integer(default=0)
installedOn = attributes.reference()
indexDirectory = attributes.text(default=XAPIAN_INDEX_DIR)
_index = attributes.inmemory()
if xapwrap is None:
def openReadIndex(self):
raise NotImplementedError("xapian is unavailable")
def openWriteIndex(self):
raise NotImplementedError("xapian is unavailable")
else:
def openReadIndex(self):
xapDir = self.store.newDirectory(self.indexDirectory)
if not xapDir.exists():
self.openWriteIndex().close()
return _XapianIndex(xapwrap.index.SmartReadOnlyIndex(str(xapDir.path)))
def openWriteIndex(self):
xapDir = self.store.newDirectory(self.indexDirectory)
return _XapianIndex(xapwrap.index.SmartIndex(str(xapDir.path), True))
try:
import PyLucene
except ImportError:
PyLucene = None
_hitsWrapperWeakrefs = weakref.WeakKeyDictionary()
class _PyLuceneHitsWrapper(record('index hits')):
"""
Container for a C{Hits} instance and the L{_PyLuceneIndex} from which it
came. This gives the C{Hits} instance a sequence-like interface and when a
_PyLuceneHitsWrapper is garbage collected, it closes the L{_PyLuceneIndex}
it has a reference to.
"""
def __init__(self, *a, **kw):
super(_PyLuceneHitsWrapper, self).__init__(*a, **kw)
def close(ref, index=self.index):
log.msg("Hits wrapper expiring, closing index.")
index.close()
_hitsWrapperWeakrefs[self] = weakref.ref(self, close)
def __len__(self):
return len(self.hits)
def __getitem__(self, index):
"""
Retrieve the storeID field of the requested hit, converting it to an
integer before returning it. This handles integer indexes as well as
slices.
"""
if isinstance(index, slice):
return [self[i] for i in xrange(*index.indices(len(self)))]
if index >= len(self.hits):
raise IndexError(index)
return int(self.hits[index]['storeID'])
class _PyLuceneBase(object):
closed = False
def __init__(self, fsdir, analyzer):
_closeObjects.append(self)
self.fsdir = fsdir
self.analyzer = analyzer
def close(self):
if not self.closed:
self._reallyClose()
self.fsdir.close()
self.closed = True
try:
_closeObjects.remove(self)
except ValueError:
pass
_closeObjects = []
def _closeIndexes():
"""
Helper for _PyLuceneIndex to make sure FSDirectory and IndexWriter
instances always get closed. This gets registered with atexit and
closes any _PyLuceneIndex objects still in _closeObjects when it gets
run.
"""
while _closeObjects:
_closeObjects[-1].close()
atexit.register(_closeIndexes)
class _PyLuceneReader(_PyLuceneBase):
"""
Searches and deletes from a Lucene index.
"""
def __init__(self, fsdir, analyzer, reader, searcher):
self.reader = reader
self.searcher = searcher
super(_PyLuceneReader, self).__init__(fsdir, analyzer)
def _reallyClose(self):
self.reader.close()
self.searcher.close()
def remove(self, documentIdentifier):
self.reader.deleteDocuments(
PyLucene.Term('storeID', documentIdentifier))
def search(self, phrase, keywords=None, sortAscending=True):
if not phrase and not keywords:
return []
# XXX Colons in phrase will screw stuff up. Can they be quoted or
# escaped somehow? Probably by using a different QueryParser.
if keywords:
fieldPhrase = u' '.join(u':'.join((k, v)) for (k, v) in keywords.iteritems())
if phrase:
phrase = phrase + u' ' + fieldPhrase
else:
phrase = fieldPhrase
qp = PyLucene.QueryParser('text', self.analyzer)
qp.setDefaultOperator(qp.Operator.AND)
query = qp.parseQuery(phrase)
sort = PyLucene.Sort(PyLucene.SortField('sortKey', not sortAscending))
try:
hits = self.searcher.search(query, sort)
except PyLucene.JavaError, err:
if 'no terms in field sortKey' in str(err):
hits = []
else:
raise
return _PyLuceneHitsWrapper(self, hits)
class _PyLuceneWriter(_PyLuceneBase):
"""
Adds documents to a Lucene index.
"""
def __init__(self, fsdir, analyzer, writer):
self.writer = writer
super(_PyLuceneWriter, self).__init__(fsdir, analyzer)
def _reallyClose(self):
self.writer.close()
def add(self, message):
doc = PyLucene.Document()
for part in message.textParts():
doc.add(
PyLucene.Field('text',
part.encode('utf-8'),
PyLucene.Field.Store.NO,
PyLucene.Field.Index.TOKENIZED))
for (k, v) in message.keywordParts().iteritems():
doc.add(
PyLucene.Field(k, v,
PyLucene.Field.Store.YES,
PyLucene.Field.Index.TOKENIZED))
doc.add(
PyLucene.Field('documentType', message.documentType(),
PyLucene.Field.Store.YES,
PyLucene.Field.Index.TOKENIZED))
doc.add(
PyLucene.Field('storeID',
message.uniqueIdentifier(),
PyLucene.Field.Store.YES,
PyLucene.Field.Index.UN_TOKENIZED))
doc.add(
PyLucene.Field('sortKey',
message.sortKey(),
PyLucene.Field.Store.YES,
PyLucene.Field.Index.UN_TOKENIZED))
# Deprecated. use Field(name, value, Field.Store.YES, Field.Index.UN_TOKENIZED) instead
self.writer.addDocument(doc)
class PyLuceneIndexer(RemoteIndexer, item.Item):
schemaVersion = 5
indexCount = attributes.integer(default=0)
installedOn = attributes.reference()
indexDirectory = attributes.text(default=LUCENE_INDEX_DIR)
_index = attributes.inmemory()
_lockfile = attributes.inmemory()
def reset(self):
"""
In addition to the behavior of the superclass, delete any dangling
lockfiles which may prevent this index from being opened. With the
tested version of PyLucene (something pre-2.0), this appears to not
actually be necessary: deleting the entire index directory but
leaving the lockfile in place seems to still allow the index to be
recreated (perhaps because when the directory does not exist, we
pass True as the create flag when opening the FSDirectory, I am
uncertain). Nevertheless, do this anyway for now.
"""
RemoteIndexer.reset(self)
if hasattr(self, '_lockfile'):
os.remove(self._lockfile)
del self._lockfile
def _analyzer(self):
return PyLucene.StandardAnalyzer([])
if PyLucene is None:
def openReadIndex(self):
raise NotImplementedError("PyLucene is unavailable")
def openWriteIndex(self):
raise NotImplementedError("PyLucene is unavailable")
else:
def openReadIndex(self):
luceneDir = self.store.newDirectory(self.indexDirectory)
if not luceneDir.exists():
self.openWriteIndex().close()
fsdir = PyLucene.FSDirectory.getDirectory(luceneDir.path, False)
try:
searcher = PyLucene.IndexSearcher(fsdir)
except PyLucene.JavaError, e:
raise IndexCorrupt()
try:
reader = PyLucene.IndexReader.open(fsdir)
except PyLucene.JavaError, e:
raise IndexCorrupt()
return _PyLuceneReader(fsdir, self._analyzer(), reader, searcher)
def openWriteIndex(self):
luceneDir = self.store.newDirectory(self.indexDirectory)
create = not luceneDir.exists()
analyzer = self._analyzer()
fsdir = PyLucene.FSDirectory.getDirectory(luceneDir.path, create)
try:
writer = PyLucene.IndexWriter(fsdir, analyzer, create)
except PyLucene.JavaError, e:
lockTimeout = u'Lock obtain timed out: Lock@'
msg = e.getJavaException().getMessage()
if msg.startswith(lockTimeout):
self._lockfile = msg[len(lockTimeout):]
raise IndexCorrupt()
return _PyLuceneWriter(fsdir, analyzer, writer)
def remoteIndexer1to2(oldIndexer):
"""
Previously external application code was responsible for adding a
RemoteListener to a batch work source as a reliable listener. This
precluded the possibility of the RemoteListener resetting itself
unilaterally. With version 2, RemoteListener takes control of adding
itself as a reliable listener and keeps track of the sources with which it
is associated. This upgrader creates that tracking state.
"""
newIndexer = oldIndexer.upgradeVersion(
oldIndexer.typeName, 1, 2,
indexCount=oldIndexer.indexCount,
installedOn=oldIndexer.installedOn,
indexDirectory=oldIndexer.indexDirectory)
listeners = newIndexer.store.query(
batch._ReliableListener,
batch._ReliableListener.listener == newIndexer)
for listener in listeners:
_IndexerInputSource(
store=newIndexer.store,
indexer=newIndexer,
source=listener.processor)
return newIndexer
def remoteIndexer2to3(oldIndexer):
"""
The documentType keyword was added to all indexable items. Indexes need to
be regenerated for this to take effect. Also, PyLucene no longer stores
the text of messages it indexes, so deleting and re-creating the indexes
will make them much smaller.
"""
newIndexer = oldIndexer.upgradeVersion(
oldIndexer.typeName, 2, 3,
indexCount=oldIndexer.indexCount,
installedOn=oldIndexer.installedOn,
indexDirectory=oldIndexer.indexDirectory)
# the 3->4 upgrader for PyLuceneIndexer calls reset(), so don't do it
# here. also, it won't work because it's a DummyItem
if oldIndexer.typeName != PyLuceneIndexer.typeName:
newIndexer.reset()
return newIndexer
def _declareLegacyIndexerItem(typeClass, version):
item.declareLegacyItem(typeClass.typeName, version,
dict(indexCount=attributes.integer(),
installedOn=attributes.reference(),
indexDirectory=attributes.text()))
for cls in [HypeIndexer, XapianIndexer, PyLuceneIndexer]:
_declareLegacyIndexerItem(cls, 2)
registerUpgrader(
remoteIndexer1to2,
item.normalize(reflect.qual(cls)),
1,
2)
registerUpgrader(
remoteIndexer2to3,
item.normalize(reflect.qual(cls)),
2,
3)
del cls
_declareLegacyIndexerItem(PyLuceneIndexer, 3)
# Copy attributes. Rely on pyLuceneIndexer4to5 to reset the index due to
# sorting changes.
registerAttributeCopyingUpgrader(PyLuceneIndexer, 3, 4)
_declareLegacyIndexerItem(PyLuceneIndexer, 4)
def pyLuceneIndexer4to5(old):
"""
Copy attributes, reset index due because information about deleted
documents has been lost, and power up for IFulltextIndexer so other code
can find this item.
"""
new = old.upgradeVersion(PyLuceneIndexer.typeName, 4, 5,
indexCount=old.indexCount,
installedOn=old.installedOn,
indexDirectory=old.indexDirectory)
new.reset()
new.store.powerUp(new, ixmantissa.IFulltextIndexer)
return new
registerUpgrader(pyLuceneIndexer4to5, PyLuceneIndexer.typeName, 4, 5)
syntax highlighted by Code2HTML, v. 0.9.1