辞書共有サービスの初期登録を改善

ずいぶん時間が空いてしまいました。今月末で 2 年近く続いたプロジェクトにようやくケリがつくので、一息つけそうな感じです。暑さもやわらいできたので、どこか旅行にでも行きたい気分。

辞書共有サービスのほうは、課題になっていた初期登録が解決しそうなので、今日はそのことについて書いてみます。

前回のあらすじと顛末

辞書共有サービスにはユーザー辞書の初期登録が必要だろうということで実装してみたところ、見出し語 4,000 件程度の登録に 8 分もかかることが判明。このままではとても使い物にならないので、辞書ファイルをアップロードした後、登録処理を cron で流すという二段階方式に変更。しかし、結局 cron でも実行時間が制限され、登録処理は無情にも打ち切られるのであった......

ということで、残念ながら、根本的に設計を見直すことになってしまいました。一番時間のかかる処理はデータストアへの書き込みなので、これをなんとか減らす以外に道はなさそうです。

見出し語を集約する

見出し語一つに 1 エンティティを使う素朴な設計では書き込みが多すぎるわけですから、1 エンティティに複数の見出し語を集約することを検討してみます。そのためにまず、集約される側のクラスと、集約する側のクラスを定義します。

class SKKEntry:
    # 見出し語と候補群、更新日時などを保持する

class SKKEntryCollection:
    # SKKEntry を辞書(dict)として保持する

次に、SKKEntryCollection をデータストアに格納するためのモデルプロパティを定義します。

class SKKEntryCollectionProperty(db.Property):
    data_type = SKKEntryCollection
    
    # 書き込み時変換
    def get_value_for_datastore(self, instance):
        base = super(SKKEntryCollectionProperty, self)
        value = base.get_value_for_datastore(instance)
        return db.Blob(pickle.dumps(value))
    
    # 読み込み時変換
    def make_value_from_datastore(self, value):
        return pickle.loads(value)

SKKEntryCollection がデータストアに書き込まれる時、pickle でシリアライズされ、バイナリデータになります。データストアから読み込まれる時にはバイナリデータから SKKEntryCollection が復元されます。これを使って最終的なモデルを定義することができます。

class SKKDictionary(db.Model):
    owner = db.StringProperty(required = True)
    collection = SKKEntryCollectionProperty(default = SKKEntryCollection())

実にすっきりとしたこのモデルの致命的な欠点は、検索条件に owner しか利用できないことです。

例えば、ある日時以降に更新された見出し語を含むエンティティを検索する、といったことができません。かわりに、各エンティティを列挙して collection のメソッドを逐一呼び出すループが必要になります。これは虚しい。けれど、仕方がない。トレードオフですからね。

独自のプロパティに対するリテラルや比較メソッドを定義できるようになるといいのでしょうが、そこが開放されるのは、かなり先のような気がします。

初期登録の実装

エンティティに見出し語を集約するにしても、上限は必要です。エンティティのサイズにも制限があるからです。そこで 1 エンティティあたり仮に 16384 個まで集約することにし、この上限を超えた場合には新しいエンティティを用意する方向で考えてみます。

このあたりの処理は煩雑になりそうなので、ひとまず初期登録をサポートするクラスとして実装してみます。

class WorkDictionary:
    def __init__(self, owner):
        self.owner = owner
        self.collections = {}
        
        query = db.Query(SKKDictionary)
        query.filter('owner = ', owner)
        
        for model in query:
            self.collections[model.key()] = model.collection
    
    def update(self, entry):
        # マージ可能ならマージ
        for collection in self.collections.itervalues():
            if entry.word in collection:
                collection.update(entry)
                return
        
        # 追加可能なら追加
        for collection in self.collections.itervalues():
            if len(collection) < 16384:
                collection[entry.word] = entry
                return
        
        # 新しいコンテナを用意して追加
        collection = SKKEntryCollection()
        collection[entry.word] = entry

        # ドイヒー
        self.collections[len(self.collections)] = collection
    
    def save(self):
        for (key, collection) in self.collections.iteritems():
            model = None

            # ドイヒー            
            if type(key) is not int:
                model = SKKDictionary.get(key)
            
            if model is None:
                model = SKKDictionary(owner = self.owner)
            
            model.collection = collection
            model.put()

定数が埋め込まれていたり、self.collections の扱いが乱暴だったり、色々と投げやりな点はありますが、このクラスを使うと初期登録のアップロード処理は以下のようになります。

    def post(self):
        user = users.get_current_user()
        post_data = self.request.get('user_dictionary')
        lines = filter(lambda x: not x.startswith(';'), post_data.splitlines())

        work = model.WorkDictionary(user.user_id())
        
        for line in lines:
            (word, delim, candidates) = line.strip().partition(' /')
            entry = model.SKKEntry(word, self.split_candidates(candidates))
            work.update(entry)
        
        work.save()
        
        ...

これを実際にローカルで動かしたところ、4,203 件の初期登録が 1 秒以内にまで短縮されました。データストアへの書き込みがいかに重いか、ということがわかりますね。

ちなみに、この処理を繰り返し実行してもパフォーマンスは一定でした。2 回目以降は候補のマージが発生するので多少遅くなるかと思っていたのですが、この程度の件数ではほとんど影響しないようです。

まとめ

見出し語を集約してデータストアへの書き込みを減らすことで、パフォーマンスが劇的に向上しました。しかし、そのためには本来ならやらなくても良いはずの泥臭い処理が必要になります。痛し痒しといったところでしょうか。

次は、このモデルに合わせて同期サービスを修正したり、ドイヒーなところを直してみようかと思います。