~loggerhead-team/loggerhead/trunk-rich

« back to all changes in this revision

Viewing changes to loggerhead/textindex.py

  • Committer: Martin Albisetti
  • Date: 2008-08-06 04:20:07 UTC
  • Revision ID: argentina@gmail.com-20080806042007-wulv8gkbxyj349qb
Don't filter something we don't filter  :)

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
#
2
 
# Copyright (C) 2006  Robey Pointer <robey@lag.net>
3
 
#
4
 
# This program is free software; you can redistribute it and/or modify
5
 
# it under the terms of the GNU General Public License as published by
6
 
# the Free Software Foundation; either version 2 of the License, or
7
 
# (at your option) any later version.
8
 
#
9
 
# This program is distributed in the hope that it will be useful,
10
 
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
 
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
 
# GNU General Public License for more details.
13
 
#
14
 
# You should have received a copy of the GNU General Public License
15
 
# along with this program; if not, write to the Free Software
16
 
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
17
 
#
18
 
 
19
 
"""
20
 
indexing of the comment text of revisions, for fast searching.
21
 
 
22
 
two separate 'shelve' files are created:
23
 
 
24
 
    - recorded: revid -> 1 (if the revid is indexed)
25
 
    - index: 3-letter substring -> list(revids)
26
 
"""
27
 
 
28
 
import logging
29
 
import os
30
 
import re
31
 
import shelve
32
 
import threading
33
 
import time
34
 
 
35
 
from loggerhead import util
36
 
from loggerhead.util import decorator
37
 
from loggerhead.lockfile import LockFile
38
 
 
39
 
# if any substring index reaches this many revids, replace the entry with
40
 
# an ALL marker -- it's not worth an explicit index.
41
 
ALL_THRESHOLD = 1000
42
 
ALL = 'ALL'
43
 
 
44
 
 
45
 
with_lock = util.with_lock('_lock')
46
 
 
47
 
 
48
 
def normalize_string(s):
49
 
    """
50
 
    remove any punctuation and normalize all whitespace to a single space.
51
 
    """
52
 
    s = util.to_utf8(s).lower()
53
 
    # remove apostrophes completely.
54
 
    s = re.sub(r"'", '', s)
55
 
    # convert other garbage into space
56
 
    s = re.sub(r'[^\w\d]', ' ', s)
57
 
    # compress multiple spaces into one.
58
 
    s = re.sub(r'\s{2,}', ' ', s)
59
 
    # and finally remove leading/trailing whitespace
60
 
    s = s.strip()
61
 
    return s
62
 
 
63
 
 
64
 
class TextIndex (object):
65
 
    def __init__(self, history, cache_path):
66
 
        self.history = history
67
 
        self.log = history.log
68
 
        
69
 
        if not os.path.exists(cache_path):
70
 
            os.mkdir(cache_path)
71
 
        
72
 
        self._recorded_filename = os.path.join(cache_path, 'textindex-recorded')
73
 
        self._index_filename = os.path.join(cache_path, 'textindex')
74
 
        
75
 
        # use a lockfile since the cache folder could be shared across different processes.
76
 
        self._lock = LockFile(os.path.join(cache_path, 'index-lock'))
77
 
        self._closed = False
78
 
        
79
 
        self.log.info('Using search index; %d entries.', len(self))
80
 
    
81
 
    def _is_indexed(self, revid, recorded):
82
 
        return recorded.get(util.to_utf8(revid), None) is not None
83
 
        
84
 
    @with_lock
85
 
    def is_indexed(self, revid):
86
 
        recorded = shelve.open(self._recorded_filename, 'c', protocol=2)
87
 
        try:
88
 
            return self._is_indexed(revid, recorded)
89
 
        finally:
90
 
            recorded.close()
91
 
    
92
 
    @with_lock
93
 
    def __len__(self):
94
 
        recorded = shelve.open(self._recorded_filename, 'c', protocol=2)
95
 
        try:
96
 
            return len(recorded)
97
 
        finally:
98
 
            recorded.close()
99
 
 
100
 
    @with_lock
101
 
    def close(self):
102
 
        self._closed = True
103
 
    
104
 
    @with_lock
105
 
    def closed(self):
106
 
        return self._closed
107
 
    
108
 
    @with_lock
109
 
    def flush(self):
110
 
        pass
111
 
    
112
 
    @with_lock
113
 
    def full(self):
114
 
        recorded = shelve.open(self._recorded_filename, 'c', protocol=2)
115
 
        try:
116
 
            return (len(recorded) >= len(self.history.get_revision_history())) and (util.to_utf8(self.history.last_revid) in recorded)
117
 
        finally:
118
 
            recorded.close()
119
 
 
120
 
    def _index_change(self, change, recorded, index):
121
 
        """
122
 
        currently, only indexes the 'comment' field.
123
 
        """
124
 
        comment = normalize_string(change.comment)
125
 
        if len(comment) < 3:
126
 
            return
127
 
        for i in xrange(len(comment) - 2):
128
 
            sub = comment[i:i + 3]
129
 
            revid_set = index.get(sub, None)
130
 
            if revid_set is None:
131
 
                revid_set = set()
132
 
            elif revid_set == ALL:
133
 
                # this entry got too big
134
 
                continue
135
 
            revid_set.add(change.revid)
136
 
            if len(revid_set) > ALL_THRESHOLD:
137
 
                revid_set = ALL
138
 
            index[sub] = revid_set
139
 
        
140
 
        recorded[util.to_utf8(change.revid)] = True
141
 
 
142
 
    @with_lock
143
 
    def index_changes(self, revid_list):
144
 
        recorded = shelve.open(self._recorded_filename, 'c', protocol=2)
145
 
        index = shelve.open(self._index_filename, 'c', protocol=2)
146
 
        try:
147
 
            revid_list = [r for r in revid_list if not self._is_indexed(r, recorded)]
148
 
            change_list = self.history.get_changes(revid_list)
149
 
            for change in change_list:
150
 
                self._index_change(change, recorded, index)
151
 
        finally:
152
 
            index.close()
153
 
            recorded.close()
154
 
    
155
 
    @with_lock
156
 
    def find(self, text, revid_list=None):
157
 
        index = shelve.open(self._index_filename, 'c', protocol=2)
158
 
        try:
159
 
            text = normalize_string(text)
160
 
            if len(text) < 3:
161
 
                return []
162
 
    
163
 
            total_set = None
164
 
            if revid_list is not None:
165
 
                total_set = set(revid_list)
166
 
            seen_all = False
167
 
            
168
 
            for i in xrange(len(text) - 2):
169
 
                sub = text[i:i + 3]
170
 
                revid_set = index.get(sub, None)
171
 
                if revid_set is None:
172
 
                    # zero matches, stop here.
173
 
                    return []
174
 
                if revid_set == ALL:
175
 
                    # skip
176
 
                    seen_all = True
177
 
                    continue
178
 
                if total_set is None:
179
 
                    total_set = revid_set
180
 
                else:
181
 
                    total_set.intersection_update(revid_set)
182
 
                if len(total_set) == 0:
183
 
                    return []
184
 
        finally:
185
 
            index.close()
186
 
        
187
 
        # tricky: if seen_all is True, one of the substring indices was ALL
188
 
        # (in other words, unindexed), so our results are actually a superset
189
 
        # of the exact answer.
190
 
        #
191
 
        # if we cared, we could do a direct match on the result set and cull
192
 
        # out any that aren't actually matches.  for now, i'm gonna say that
193
 
        # we DON'T care, and if one of the substrings hit ALL, there's a small
194
 
        # chance that we'll give a few false positives.
195
 
        return total_set
196
 
    
197
 
    def check_rebuild(self, max_time=3600):
198
 
        """
199
 
        check if there are any un-indexed revisions, and if so, index them.
200
 
        but don't spend longer than C{max_time} on it.
201
 
        """
202
 
        if self.closed() or self.full():
203
 
            # all done
204
 
            return
205
 
 
206
 
        self.log.info('Building search index...')
207
 
        work = list(self.history.get_revision_history())
208
 
        start_time = time.time()
209
 
        last_update = time.time()
210
 
        count = 0
211
 
 
212
 
        jump = 100
213
 
        for i in xrange(0, len(work), jump):
214
 
            r = work[i:i + jump]
215
 
            self.index_changes(r)
216
 
            if self.closed():
217
 
                return
218
 
 
219
 
            count += jump
220
 
            now = time.time()
221
 
            if now - start_time > 3600:
222
 
                # there's no point working for hours.  eventually we might even
223
 
                # hit the next re-index interval, which would suck mightily.
224
 
                self.log.info('Search indexing has worked for an hour; giving up for now.')
225
 
                return
226
 
            if now - last_update > 60:
227
 
                self.log.info('Search indexing continues: %d/%d' % (min(count, len(work)), len(work)))
228
 
                last_update = time.time()
229
 
            # give someone else a chance at the lock
230
 
            time.sleep(1)
231
 
        self.log.info('Search index completed.')
232
 
        self.flush()
233