From 61093d7e0aa00a3e5cfcc27d0c9dbe5fe5d0abd1 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Wed, 3 May 2017 15:37:25 -0400 Subject: [PATCH 01/11] Reimplement based on the npm change stream --- .travis.yml | 2 +- README.md | 15 ++-- gratipay/cli/sync_npm.py | 92 ++++++++++++++++++----- gratipay/sync_npm/__init__.py | 43 ----------- gratipay/sync_npm/serialize.py | 107 --------------------------- gratipay/sync_npm/upsert.py | 57 --------------- gratipay/testing/harness.py | 1 + gratipay/utils/sentry.py | 31 ++++++++ requirements.txt | 2 +- sql/branch.sql | 4 + tests/py/test_sync_npm.py | 130 +++++++++++++-------------------- tests/py/test_utils.py | 25 ++++++- vendor/CouchDB-1.1.tar.gz | Bin 0 -> 60839 bytes vendor/ijson-2.3.tar.gz | Bin 10344 -> 0 bytes 14 files changed, 192 insertions(+), 317 deletions(-) delete mode 100644 gratipay/sync_npm/__init__.py delete mode 100644 gratipay/sync_npm/serialize.py delete mode 100644 gratipay/sync_npm/upsert.py create mode 100644 gratipay/utils/sentry.py create mode 100644 sql/branch.sql create mode 100644 vendor/CouchDB-1.1.tar.gz delete mode 100644 vendor/ijson-2.3.tar.gz diff --git a/.travis.yml b/.travis.yml index a381534f24..030099df09 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,7 +3,7 @@ language: python git: depth: 5 addons: - postgresql: 9.3 + postgresql: 9.6 firefox: latest-esr before_install: - git branch -vv | grep '^*' diff --git a/README.md b/README.md index 9a39af6314..4723af6bcf 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Quick Start Local ----- -Given Python 2.7, Postgres 9.3, and a C/make toolchain: +Given Python 2.7, Postgres 9.6, and a C/make toolchain: ```shell git clone https://github.com/gratipay/gratipay.com.git @@ -116,7 +116,7 @@ On Debian or Ubuntu you will need the following packages: ```shell sudo apt-get install \ - postgresql-9.3 \ + postgresql-9.6 \ postgresql-contrib \ libpq-dev \ python-dev \ @@ -386,7 +386,7 @@ Modifying the Database ====================== We write SQL, specifically the [PostgreSQL -variant](https://www.postgresql.org/docs/9.3/static/). We keep our database +variant](https://www.postgresql.org/docs/9.6/static/). We keep our database schema in [`schema.sql`](https://github.com/gratipay/gratipay.com/blob/master/sql/schema.sql), and we write schema changes for each PR branch in a `sql/branch.sql` file, which @@ -436,11 +436,10 @@ database configured in your testing environment. Local Database Setup -------------------- -For the best development experience, you need a local -installation of [Postgres](https://www.postgresql.org/download/). The best -version of Postgres to use is 9.3.5, because that's what we're using in -production at Heroku. You need at least 9.2, because we depend on being able to -specify a URI to `psql`, and that was added in 9.2. +For the best development experience, you need a local installation of +[Postgres](https://www.postgresql.org/download/). The best version of Postgres +to use is 9.6.2, because that's what we're using in production at Heroku. You +need at least 9.5 to support the features we depend on. + Mac: use Homebrew: `brew install postgres` + Ubuntu: use Apt: `apt-get install postgresql postgresql-contrib libpq-dev` diff --git a/gratipay/cli/sync_npm.py b/gratipay/cli/sync_npm.py index 8dcf62e907..8ed7fc35da 100644 --- a/gratipay/cli/sync_npm.py +++ b/gratipay/cli/sync_npm.py @@ -3,40 +3,92 @@ """ from __future__ import absolute_import, division, print_function, unicode_literals -import sys -import argparse +import time + +from aspen import log +from couchdb import Database from gratipay import wireup -from gratipay.sync_npm import serialize, upsert +from gratipay.utils import sentry + + +def get_last_seq(db): + return db.one('SELECT npm_last_seq FROM worker_coordination') + + +def production_change_stream(seq): + """Given a sequence number in the npm registry change stream, start + streaming from there! + """ + npm = Database('https://skimdb.npmjs.com/registry') + return npm.changes(feed='continuous', include_docs=True, since=seq) + + +def process_doc(doc): + """Return a smoothed-out doc, or None if it's not a package doc, meaning + there's no name key and it's probably a design doc, per: + https://github.com/npm/registry/blob/aef8a275/docs/follower.md#clean-up -def parse_args(argv): - p = argparse.ArgumentParser() - p.add_argument('command', choices=['serialize', 'upsert']) - p.add_argument('path', help='the path to the input file', nargs='?', default='/dev/stdin') - return p.parse_args(argv) + """ + if 'name' not in doc: + return None + name = doc['name'] + description = doc.get('description', '') + emails = [e for e in [m.get('email') for m in doc.get('maintainers', [])] if e.strip()] + return {'name': name, 'description': description, 'emails': sorted(set(emails))} + + +def consume_change_stream(change_stream, db): + """Given a function similar to :py:func:`production_change_stream` and a + :py:class:`~GratipayDB`, read from the stream and write to the db. + + The npm registry is a CouchDB app, which means we get a change stream from + it that allows us to follow registry updates in near-realtime. Our strategy + here is to maintain open connections to both the registry and our own + database, and write as we read. + """ + last_seq = get_last_seq(db) + log("Picking up with npm sync at {}.".format(last_seq)) + with db.get_connection() as conn: + for change in change_stream(last_seq): + processed = process_doc(change['doc']) + if not processed: + continue + cursor = conn.cursor() + cursor.run(''' + INSERT INTO packages + (package_manager, name, description, emails) + VALUES ('npm', %(name)s, %(description)s, %(emails)s) -subcommands = { 'serialize': serialize.main - , 'upsert': upsert.main - } + ON CONFLICT (package_manager, name) DO UPDATE + SET description=%(description)s, emails=%(emails)s + ''', processed) + cursor.run('UPDATE worker_coordination SET npm_last_seq=%s', (change['seq'],)) + cursor.connection.commit() -def main(argv=sys.argv): +def main(): """This function is installed via an entrypoint in ``setup.py`` as ``sync-npm``. Usage:: - sync-npm {serialize,upsert} {} - - ```` defaults to stdin. - - .. note:: Sphinx is expanding ``sys.argv`` in the parameter list. Sorry. :-/ + sync-npm """ env = wireup.env() - args = parse_args(argv[1:]) db = wireup.db(env) - - subcommands[args.command](env, args, db) + while 1: + with sentry.teller(env): + consume_change_stream(production_change_stream, db) + try: + last_seq = get_last_seq(db) + sleep_for = 60 + log( 'Encountered an error, will pick up with %s in %s seconds (Ctrl-C to exit) ...' + % (last_seq, sleep_for) + ) + time.sleep(sleep_for) # avoid a busy loop if thrashing + except KeyboardInterrupt: + return diff --git a/gratipay/sync_npm/__init__.py b/gratipay/sync_npm/__init__.py deleted file mode 100644 index 9bed79adcd..0000000000 --- a/gratipay/sync_npm/__init__.py +++ /dev/null @@ -1,43 +0,0 @@ -"""This is the code behind the ``sync-npm`` command line tool, which keeps the -``packages`` table in our database in sync with npm. We run it on asynchronous -worker dynos at Heroku using the `Heroku scheduler`_. The top-level command is -at ``cli.main``, and the subcommands are in ``main`` in the other modules. - -.. _Heroku scheduler: https://devcenter.heroku.com/articles/scheduler - -""" -from __future__ import absolute_import, division, print_function, unicode_literals - -import sys -from threading import Lock - -from gratipay import wireup - - -log_lock = Lock() - -def log(*a, **kw): - """Log to stderr, thread-safely. - """ - with log_lock: - print(*a, file=sys.stderr, **kw) - - -class sentry(object): - """This is a context manager to log to sentry. You have to pass in an ``Environment`` - object with a ``sentry_dsn`` attribute. - """ - - def __init__(self, env, noop=None): - try: - sys.stdout = sys.stderr # work around aspen.log_dammit limitation; sigh - self.tell_sentry = wireup.make_sentry_teller(env, noop) - finally: - sys.stdout = sys.__stdout__ - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc_value, traceback): - self.tell_sentry(exc_type, {}) - return False diff --git a/gratipay/sync_npm/serialize.py b/gratipay/sync_npm/serialize.py deleted file mode 100644 index 2ab3016ba5..0000000000 --- a/gratipay/sync_npm/serialize.py +++ /dev/null @@ -1,107 +0,0 @@ -# -*- coding: utf-8 -*- -"""Subcommand for serializing JSON from npm into CSV. -""" -from __future__ import absolute_import, division, print_function, unicode_literals - -import csv -import sys -import time - -from . import log, sentry - - -def import_ijson(env): - if env.require_yajl: - import ijson.backends.yajl2_cffi as ijson - else: - import ijson - return ijson - - -def arrayize(seq): - """Given a sequence of ``str``, return a Postgres array literal ``str``. - This is scary and I wish ``psycopg2`` had something we could use. - - """ - array = [] - for item in seq: - assert type(item) is str - escaped = item.replace(b'\\', b'\\\\').replace(b'"', b'\\"') - quoted = b'"' + escaped + b'"' - array.append(quoted) - joined = b', '.join(array) - return b'{' + joined + b'}' - - -def serialize_one(out, package): - """Take a single package ``dict`` and emit a CSV serialization suitable for - Postgres COPY. - - """ - if not package or package['name'].startswith('_'): - log('skipping', package) - return 0 - - row = ( package['package_manager'] - , package['name'] - , package['description'] - , arrayize(package['emails']) - ) - - out.writerow(row) - return 1 - - -def serialize(env, args, db): - ijson = import_ijson(env) - - path = args.path - parser = ijson.parse(open(path)) - start = time.time() - package = None - nprocessed = 0 - out = csv.writer(sys.stdout) - - def log_stats(): - log("processed {} packages in {:3.0f} seconds" - .format(nprocessed, time.time() - start)) - - for prefix, event, value in parser: - - if not prefix and event == b'map_key': - - # Flush the current package. We count on the first package being garbage. - processed = serialize_one(out, package) - nprocessed += processed - if processed and not(nprocessed % 1000): - log_stats() - - # Start a new package. - package = { 'package_manager': b'npm' - , 'name': value - , 'description': b'' - , 'emails': [] - } - - key = lambda k: package['name'] + b'.' + k - - if event == b'string': - assert type(value) is unicode # Who knew? Seems to decode only for `string`. - value = value.encode('utf8') - if prefix == key(b'description'): - package['description'] = value - elif prefix in (key(b'author.email'), key(b'maintainers.item.email')): - package['emails'].append(value) - - nprocessed += serialize_one(out, package) # Don't forget the last one! - log_stats() - - -def main(env, args, db): - """Consume raw JSON from the npm registry via ``args.path``, and spit out - CSV for Postgres to stdout. Uses ``ijson``, requiring the ``yajl_cffi`` - backend if ``env.require_yajl`` is ``True``. - - """ - with sentry(env): - serialize(env, args, db) diff --git a/gratipay/sync_npm/upsert.py b/gratipay/sync_npm/upsert.py deleted file mode 100644 index 86f3f4f8ea..0000000000 --- a/gratipay/sync_npm/upsert.py +++ /dev/null @@ -1,57 +0,0 @@ -# -*- coding: utf-8 -*- -"""Subcommand for upserting data from a CSV into Postgres. -""" -from __future__ import absolute_import, division, print_function, unicode_literals - -import uuid - -from . import sentry - - -# Coordinate with Postgres on how to say "NULL". -# ============================================== -# We can't use the default, which is the empty string, because then we can't -# easily store the empty string itself. We don't want to use something that a -# package author could maliciously or mischieviously take advantage of to -# indicate a null we don't want. If we use a uuid it should be hard enough to -# guess, made harder in that it will change for each processing run. - -NULL = uuid.uuid4().hex - - -def upsert(env, args, db): - fp = open(args.path) - with db.get_cursor() as cursor: - assert cursor.connection.encoding == 'UTF8' - - cursor.run("CREATE TEMP TABLE updates (LIKE packages INCLUDING ALL) ON COMMIT DROP") - cursor.copy_expert('COPY updates (package_manager, name, description, emails) ' - "FROM STDIN WITH (FORMAT csv, NULL '%s')" % NULL, fp) - cursor.run(""" - - WITH updated AS ( - UPDATE packages p - SET package_manager = u.package_manager - , description = u.description - , emails = u.emails - FROM updates u - WHERE p.name = u.name - RETURNING p.name - ) - INSERT INTO packages(package_manager, name, description, emails) - SELECT package_manager, name, description, emails - FROM updates u LEFT JOIN updated USING(name) - WHERE updated.name IS NULL - GROUP BY u.package_manager, u.name, u.description, u.emails - - """) - - -def main(env, args, db): - """Take a CSV file from stdin and load it into Postgres using an `ingenious algorithm`_. - - .. _ingenious algorithm: http://tapoueh.org/blog/2013/03/15-batch-update.html - - """ - with sentry(env): - upsert(env, args, db) diff --git a/gratipay/testing/harness.py b/gratipay/testing/harness.py index 82e132ceee..bcab9f9846 100644 --- a/gratipay/testing/harness.py +++ b/gratipay/testing/harness.py @@ -134,6 +134,7 @@ def clear_tables(self): except (IntegrityError, InternalError): tablenames.insert(0, tablename) self.db.run("ALTER SEQUENCE participants_id_seq RESTART WITH 1") + self.db.run("INSERT INTO worker_coordination DEFAULT VALUES") def make_elsewhere(self, platform, user_id, user_name, **kw): diff --git a/gratipay/utils/sentry.py b/gratipay/utils/sentry.py new file mode 100644 index 0000000000..02d552d38e --- /dev/null +++ b/gratipay/utils/sentry.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals + +import sys +import traceback + +from aspen import log +from gratipay import wireup + + +log_to_console = lambda exc_type, state: log(traceback.format_exc()) + + +class teller(object): + """This is a context manager to log to Sentry. You have to pass in an + ``Environment`` object with a ``sentry_dsn`` attribute. + """ + + def __init__(self, env, fallback=log_to_console): + try: + sys.stdout = sys.stderr # work around aspen.log_dammit limitation; sigh + self.tell_sentry = wireup.make_sentry_teller(env, fallback) + finally: + sys.stdout = sys.__stdout__ + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + self.tell_sentry(exc_type, {}) + return True diff --git a/requirements.txt b/requirements.txt index e177fdbfa6..888f402083 100644 --- a/requirements.txt +++ b/requirements.txt @@ -68,6 +68,6 @@ ./vendor/ipaddress-1.0.16.tar.gz ./vendor/cryptography-1.5.3.tar.gz -./vendor/ijson-2.3.tar.gz +./vendor/CouchDB-1.1.tar.gz -e . diff --git a/sql/branch.sql b/sql/branch.sql new file mode 100644 index 0000000000..bdd16c7cc4 --- /dev/null +++ b/sql/branch.sql @@ -0,0 +1,4 @@ +BEGIN; + CREATE TABLE worker_coordination (npm_last_seq bigint not null default -1); + INSERT INTO worker_coordination DEFAULT VALUES; +END; diff --git a/tests/py/test_sync_npm.py b/tests/py/test_sync_npm.py index df0cea9b1b..a8d6e9f622 100644 --- a/tests/py/test_sync_npm.py +++ b/tests/py/test_sync_npm.py @@ -1,108 +1,80 @@ # -*- coding: utf-8 -*- from __future__ import absolute_import, division, print_function, unicode_literals -from subprocess import Popen, PIPE +from gratipay.testing import Harness -import pytest +from gratipay.cli import sync_npm -from gratipay import sync_npm -from gratipay.testing import Harness +class ProcessDocTests(Harness): + + def test_returns_None_if_no_name(self): + assert sync_npm.process_doc({}) is None -def load(raw): - serialized = Popen( ('env/bin/sync-npm', 'serialize', '/dev/stdin') - , stdin=PIPE, stdout=PIPE - ).communicate(raw)[0] - Popen( ('env/bin/sync-npm', 'upsert', '/dev/stdin') - , stdin=PIPE, stdout=PIPE - ).communicate(serialized)[0] + def test_backfills_missing_keys(self): + actual = sync_npm.process_doc({'name': 'foo'}) + assert actual == {'name': 'foo', 'description': '', 'emails': []} + def test_extracts_maintainer_emails(self): + doc = {'name': 'foo', 'maintainers': [{'email': 'alice@example.com'}]} + assert sync_npm.process_doc(doc)['emails'] == ['alice@example.com'] -class FailCollector: + def test_skips_empty_emails(self): + doc = {'name': 'foo', 'maintainers': [{'email': ''}, {'email': ' '}]} + assert sync_npm.process_doc(doc)['emails'] == [] - def __init__(self): - self.fails = [] + def test_sorts_emails(self): + doc = {'name': 'foo', 'maintainers': [{'email': 'bob'}, {'email': 'alice'}]} + assert sync_npm.process_doc(doc)['emails'] == ['alice', 'bob'] - def __call__(self, fail, whatever): - self.fails.append(fail) + def test_dedupes_emails(self): + doc = {'name': 'foo', 'maintainers': [{'email': 'alice'}, {'email': 'alice'}]} + assert sync_npm.process_doc(doc)['emails'] == ['alice'] -class Heck(Exception): - pass +class ConsumeChangeStreamTests(Harness): + def change_stream(self, docs): + def change_stream(seq): + for i, doc in enumerate(docs): + if i < seq: continue + yield {'seq': i, 'doc': doc} + return change_stream -class Tests(Harness): def test_packages_starts_empty(self): assert self.db.all('select * from packages') == [] - # sn - sync-npm - - def test_sn_inserts_packages(self): - load(br''' - { "_updated": 1234567890 - , "testing-package": - { "name":"testing-package" - , "description":"A package for testing" - , "maintainers":[{"email":"alice@example.com"}] - , "author": {"email":"bob@example.com"} - , "time":{"modified":"2015-09-12T03:03:03.135Z"} - } - } - ''') + def test_consumes_change_stream(self): + docs = [ {'name': 'foo', 'description': 'Foo.'} + , {'name': 'foo', 'description': 'Foo?'} + , {'name': 'foo', 'description': 'Foo!'} + ] + sync_npm.consume_change_stream(self.change_stream(docs), self.db) package = self.db.one('select * from packages') assert package.package_manager == 'npm' - assert package.name == 'testing-package' - assert package.description == 'A package for testing' - assert package.name == 'testing-package' - - - def test_sn_handles_quoting(self): - load(br''' - { "_updated": 1234567890 - , "testi\\\"ng-pa\\\"ckage": - { "name":"testi\\\"ng-pa\\\"ckage" - , "description":"A package for \"testing\"" - , "maintainers":[{"email":"alice@\"example\".com"}] - , "author": {"email":"\\\\\"bob\\\\\"@example.com"} - , "time":{"modified":"2015-09-12T03:03:03.135Z"} - } - } - ''') + assert package.name == 'foo' + assert package.description == 'Foo!' + assert package.emails == [] - package = self.db.one('select * from packages') - assert package.package_manager == 'npm' - assert package.name == r'testi\"ng-pa\"ckage' - assert package.description == 'A package for "testing"' - assert package.emails == ['alice@"example".com', r'\\"bob\\"@example.com'] - - - def test_sn_handles_empty_description_and_emails(self): - load(br''' - { "_updated": 1234567890 - , "empty-description": - { "name":"empty-description" - , "description":"" - , "time":{"modified":"2015-09-12T03:03:03.135Z"} - } - } - ''') + + def test_picks_up_with_last_seq(self): + docs = [ {'name': 'foo', 'description': 'Foo.'} + , {'name': 'foo', 'description': 'See alice?', 'maintainers': [{'email': 'alice'}]} + , {'name': 'foo', 'description': "No, I don't see alice!"} + ] + self.db.run('update worker_coordination set npm_last_seq=2') + sync_npm.consume_change_stream(self.change_stream(docs), self.db) package = self.db.one('select * from packages') - assert package.package_manager == 'npm' - assert package.name == 'empty-description' - assert package.description == '' + assert package.description == "No, I don't see alice!" assert package.emails == [] - # with sentry(env) - - def test_with_sentry_logs_to_sentry_and_raises(self): - class env: sentry_dsn = '' - noop = FailCollector() - with pytest.raises(Heck): - with sync_npm.sentry(env, noop): - raise Heck - assert noop.fails == [Heck] + def test_sets_last_seq(self): + docs = [{'name': 'foo', 'description': 'Foo.'}] * 13 + assert self.db.one('select npm_last_seq from worker_coordination') == -1 + sync_npm.consume_change_stream(self.change_stream(docs), self.db) + assert self.db.one('select npm_last_seq from worker_coordination') == 12 diff --git a/tests/py/test_utils.py b/tests/py/test_utils.py index a121c9bf47..0c471cc424 100644 --- a/tests/py/test_utils.py +++ b/tests/py/test_utils.py @@ -8,7 +8,7 @@ from gratipay import utils from gratipay.testing import Harness, D from gratipay.utils import i18n, pricing, encode_for_querystring, decode_from_querystring, \ - truncate, get_featured_projects + sentry, truncate, get_featured_projects from gratipay.utils.username import safely_reserve_a_username, FailedToReserveUsername, \ RanOutOfUsernameAttempts from psycopg2 import IntegrityError @@ -300,3 +300,26 @@ def test_deals_with_zero_projects(self): def test_deals_with_some_but_too_few_of_both(self): assert self.get_and_count(range(4), list('A')) == (4, 1) + + +class FailCollector: + + def __init__(self): + self.fails = [] + + def __call__(self, fail, whatever): + self.fails.append(fail) + + +class Heck(Exception): + pass + + +class SentryTellerTests(Harness): + + def test_with_sentry_logs_to_sentry_and_continues(self): + class env: sentry_dsn = '' + noop = FailCollector() + with sentry.teller(env, noop): + raise Heck + assert noop.fails == [Heck] diff --git a/vendor/CouchDB-1.1.tar.gz b/vendor/CouchDB-1.1.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..06660cde97f0b1569012c802b029453868d8e08f GIT binary patch literal 60839 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 Y>Z-2ls;=s)uIj?;KU6%B+yICK0HZRISO5S3 literal 0 HcmV?d00001 diff --git a/vendor/ijson-2.3.tar.gz b/vendor/ijson-2.3.tar.gz deleted file mode 100644 index e5dd11238d7a90daa019f98df00eea5f640e487a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 10344 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 Date: Thu, 4 May 2017 11:06:56 -0400 Subject: [PATCH 02/11] Log lag metric to stdout for Librato --- defaults.env | 1 + gratipay/application.py | 4 +- gratipay/cli/sync_npm.py | 59 +-------------- gratipay/sync_npm.py | 71 +++++++++++++++++++ gratipay/wireup.py | 1 + .../py/fixtures/ConsumeChangeStreamTests.yml | 22 ++++++ tests/py/test_sync_npm.py | 12 +++- 7 files changed, 110 insertions(+), 60 deletions(-) create mode 100644 gratipay/sync_npm.py create mode 100644 tests/py/fixtures/ConsumeChangeStreamTests.yml diff --git a/defaults.env b/defaults.env index 361ac6f8bc..49a76440ac 100644 --- a/defaults.env +++ b/defaults.env @@ -79,6 +79,7 @@ EMAIL_QUEUE_ALLOW_UP_TO=3 UPDATE_CTA_EVERY=300 CHECK_DB_EVERY=600 +CHECK_NPM_SYNC_EVERY=60 OPTIMIZELY_ID= INCLUDE_PIWIK=no SENTRY_DSN= diff --git a/gratipay/application.py b/gratipay/application.py index 680238501a..829ae894cc 100644 --- a/gratipay/application.py +++ b/gratipay/application.py @@ -3,7 +3,7 @@ import psycopg2.extras -from . import email, utils +from . import email, sync_npm, utils from .cron import Cron from .models import GratipayDB from .payday_runner import PaydayRunner @@ -53,6 +53,8 @@ def install_periodic_jobs(self, website, env, db): cron(env.update_cta_every, lambda: utils.update_cta(website)) cron(env.check_db_every, db.self_check, True) cron(env.email_queue_flush_every, self.email_queue.flush, True) + if website.log_metrics: + cron(env.check_npm_sync_every, lambda: sync_npm.check(db), True) def add_event(self, c, type, payload): diff --git a/gratipay/cli/sync_npm.py b/gratipay/cli/sync_npm.py index 8ed7fc35da..e2fa597506 100644 --- a/gratipay/cli/sync_npm.py +++ b/gratipay/cli/sync_npm.py @@ -6,69 +6,12 @@ import time from aspen import log -from couchdb import Database from gratipay import wireup +from gratipay.sync_npm import consume_change_stream, get_last_seq, production_change_stream from gratipay.utils import sentry -def get_last_seq(db): - return db.one('SELECT npm_last_seq FROM worker_coordination') - - -def production_change_stream(seq): - """Given a sequence number in the npm registry change stream, start - streaming from there! - """ - npm = Database('https://skimdb.npmjs.com/registry') - return npm.changes(feed='continuous', include_docs=True, since=seq) - - -def process_doc(doc): - """Return a smoothed-out doc, or None if it's not a package doc, meaning - there's no name key and it's probably a design doc, per: - - https://github.com/npm/registry/blob/aef8a275/docs/follower.md#clean-up - - """ - if 'name' not in doc: - return None - name = doc['name'] - description = doc.get('description', '') - emails = [e for e in [m.get('email') for m in doc.get('maintainers', [])] if e.strip()] - return {'name': name, 'description': description, 'emails': sorted(set(emails))} - - -def consume_change_stream(change_stream, db): - """Given a function similar to :py:func:`production_change_stream` and a - :py:class:`~GratipayDB`, read from the stream and write to the db. - - The npm registry is a CouchDB app, which means we get a change stream from - it that allows us to follow registry updates in near-realtime. Our strategy - here is to maintain open connections to both the registry and our own - database, and write as we read. - - """ - last_seq = get_last_seq(db) - log("Picking up with npm sync at {}.".format(last_seq)) - with db.get_connection() as conn: - for change in change_stream(last_seq): - processed = process_doc(change['doc']) - if not processed: - continue - cursor = conn.cursor() - cursor.run(''' - INSERT INTO packages - (package_manager, name, description, emails) - VALUES ('npm', %(name)s, %(description)s, %(emails)s) - - ON CONFLICT (package_manager, name) DO UPDATE - SET description=%(description)s, emails=%(emails)s - ''', processed) - cursor.run('UPDATE worker_coordination SET npm_last_seq=%s', (change['seq'],)) - cursor.connection.commit() - - def main(): """This function is installed via an entrypoint in ``setup.py`` as ``sync-npm``. diff --git a/gratipay/sync_npm.py b/gratipay/sync_npm.py new file mode 100644 index 0000000000..b6a788638c --- /dev/null +++ b/gratipay/sync_npm.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals + +import requests +from aspen import log +from couchdb import Database + + +REGISTRY_URL = 'https://skimdb.npmjs.com/registry' + + +def get_last_seq(db): + return db.one('SELECT npm_last_seq FROM worker_coordination') + + +def production_change_stream(seq): + """Given a sequence number in the npm registry change stream, start + streaming from there! + """ + return Database(REGISTRY_URL).changes(feed='continuous', include_docs=True, since=seq) + + +def process_doc(doc): + """Return a smoothed-out doc, or None if it's not a package doc, meaning + there's no name key and it's probably a design doc, per: + + https://github.com/npm/registry/blob/aef8a275/docs/follower.md#clean-up + + """ + if 'name' not in doc: + return None + name = doc['name'] + description = doc.get('description', '') + emails = [e for e in [m.get('email') for m in doc.get('maintainers', [])] if e.strip()] + return {'name': name, 'description': description, 'emails': sorted(set(emails))} + + +def consume_change_stream(change_stream, db): + """Given a function similar to :py:func:`production_change_stream` and a + :py:class:`~GratipayDB`, read from the stream and write to the db. + + The npm registry is a CouchDB app, which means we get a change stream from + it that allows us to follow registry updates in near-realtime. Our strategy + here is to maintain open connections to both the registry and our own + database, and write as we read. + + """ + last_seq = get_last_seq(db) + log("Picking up with npm sync at {}.".format(last_seq)) + with db.get_connection() as conn: + for change in change_stream(last_seq): + processed = process_doc(change['doc']) + if not processed: + continue + cursor = conn.cursor() + cursor.run(''' + INSERT INTO packages + (package_manager, name, description, emails) + VALUES ('npm', %(name)s, %(description)s, %(emails)s) + + ON CONFLICT (package_manager, name) DO UPDATE + SET description=%(description)s, emails=%(emails)s + ''', processed) + cursor.run('UPDATE worker_coordination SET npm_last_seq=%s', (change['seq'],)) + cursor.connection.commit() + + +def check(db, _print=print): + ours = db.one('SELECT npm_last_seq FROM worker_coordination') + theirs = int(requests.get(REGISTRY_URL).json()['update_seq']) + _print("count#npm-sync-lag={}".format(theirs - ours)) diff --git a/gratipay/wireup.py b/gratipay/wireup.py index f6c36b73ff..b624ed5709 100644 --- a/gratipay/wireup.py +++ b/gratipay/wireup.py @@ -386,6 +386,7 @@ def env(): OPENSTREETMAP_AUTH_URL = unicode, UPDATE_CTA_EVERY = int, CHECK_DB_EVERY = int, + CHECK_NPM_SYNC_EVERY = int, EMAIL_QUEUE_FLUSH_EVERY = int, EMAIL_QUEUE_SLEEP_FOR = int, EMAIL_QUEUE_ALLOW_UP_TO = int, diff --git a/tests/py/fixtures/ConsumeChangeStreamTests.yml b/tests/py/fixtures/ConsumeChangeStreamTests.yml new file mode 100644 index 0000000000..d1cb53ec09 --- /dev/null +++ b/tests/py/fixtures/ConsumeChangeStreamTests.yml @@ -0,0 +1,22 @@ +interactions: +- request: + body: null + headers: {} + method: GET + uri: https://skimdb.npmjs.com:443/registry + response: + body: + string: !!binary | + H4sIAAAAAAAEA22RzW7DIBCE732MPftg/sEvgyisXVQbp4CjtlHevTh1JKvqdZaZnf24QXi1yS0I + A2ScYqn5CzoIq7d+3VKFgXPdE/YrBZyfstKcd7BdgqtoC37AQIwSXOgOLlueDq3vwK/Lxflq85ZS + TBMMo5sLtrxY3m2J320zZUwbRbRUHaz1DTMMN2jB7pgT1WsmCKPq3nwnvbmE0Ua0entS2X1jnP9E + tu3x2jRyeo6fFXNyc1PP4TGV6pJv7avL1db4AEO4NlwqSgQxkjKxA9rbj2teXLVXzCWuCQb5uHaJ + tWKw/6I5YLTxExnttWgctxjaD1BJQmBcmjF4JGNPzag5E1IH9MaJAPeXH2Iq25KxAQAA + headers: + cache-control: [must-revalidate] + content-encoding: [gzip] + content-type: [application/json] + strict-transport-security: [max-age=2592000000; includeSubDomains; preload;] + transfer-encoding: [chunked] + status: {code: 200, message: OK} +version: 1 diff --git a/tests/py/test_sync_npm.py b/tests/py/test_sync_npm.py index a8d6e9f622..12568f0875 100644 --- a/tests/py/test_sync_npm.py +++ b/tests/py/test_sync_npm.py @@ -3,7 +3,7 @@ from gratipay.testing import Harness -from gratipay.cli import sync_npm +from gratipay import sync_npm class ProcessDocTests(Harness): @@ -78,3 +78,13 @@ def test_sets_last_seq(self): assert self.db.one('select npm_last_seq from worker_coordination') == -1 sync_npm.consume_change_stream(self.change_stream(docs), self.db) assert self.db.one('select npm_last_seq from worker_coordination') == 12 + + + def test_logs_lag(self): + captured = {} + def capture(message): + captured['message'] = message + self.db.run('update worker_coordination set npm_last_seq=500') + sync_npm.check(self.db, capture) + assert captured['message'].startswith('count#npm-sync-lag=') + assert captured['message'].split('=')[1].isdigit() From 83cdfdabd7e75f5088723a95bbb60db97327fa9d Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Thu, 4 May 2017 12:54:04 -0400 Subject: [PATCH 03/11] Delete packages during change stream processing --- gratipay/sync_npm.py | 39 ++++++++++++++++++++++++++------------- tests/py/test_sync_npm.py | 34 ++++++++++++++++++++++++---------- 2 files changed, 50 insertions(+), 23 deletions(-) diff --git a/gratipay/sync_npm.py b/gratipay/sync_npm.py index b6a788638c..39e062b4e9 100644 --- a/gratipay/sync_npm.py +++ b/gratipay/sync_npm.py @@ -47,22 +47,35 @@ def consume_change_stream(change_stream, db): """ last_seq = get_last_seq(db) log("Picking up with npm sync at {}.".format(last_seq)) - with db.get_connection() as conn: + with db.get_connection() as connection: for change in change_stream(last_seq): - processed = process_doc(change['doc']) + if change.get('deleted'): + # Hack to work around conflation of design docs and packages in updates + op, doc = delete, {'name': change['id']} + else: + op, doc = upsert, change['doc'] + processed = process_doc(doc) if not processed: continue - cursor = conn.cursor() - cursor.run(''' - INSERT INTO packages - (package_manager, name, description, emails) - VALUES ('npm', %(name)s, %(description)s, %(emails)s) - - ON CONFLICT (package_manager, name) DO UPDATE - SET description=%(description)s, emails=%(emails)s - ''', processed) - cursor.run('UPDATE worker_coordination SET npm_last_seq=%s', (change['seq'],)) - cursor.connection.commit() + cursor = connection.cursor() + op(cursor, processed) + cursor.run('UPDATE worker_coordination SET npm_last_seq=%(seq)s', change) + connection.commit() + + +def delete(cursor, processed): + cursor.run("DELETE FROM packages WHERE package_manager='npm' AND name=%(name)s", processed) + + +def upsert(cursor, processed): + cursor.run(''' + INSERT INTO packages + (package_manager, name, description, emails) + VALUES ('npm', %(name)s, %(description)s, %(emails)s) + + ON CONFLICT (package_manager, name) DO UPDATE + SET description=%(description)s, emails=%(emails)s + ''', processed) def check(db, _print=print): diff --git a/tests/py/test_sync_npm.py b/tests/py/test_sync_npm.py index 12568f0875..dfcbc6e2d9 100644 --- a/tests/py/test_sync_npm.py +++ b/tests/py/test_sync_npm.py @@ -34,11 +34,12 @@ def test_dedupes_emails(self): class ConsumeChangeStreamTests(Harness): - def change_stream(self, docs): + def change_stream(self, changes): def change_stream(seq): - for i, doc in enumerate(docs): + for i, change in enumerate(changes): if i < seq: continue - yield {'seq': i, 'doc': doc} + change['seq'] = i + yield change return change_stream @@ -47,9 +48,9 @@ def test_packages_starts_empty(self): def test_consumes_change_stream(self): - docs = [ {'name': 'foo', 'description': 'Foo.'} - , {'name': 'foo', 'description': 'Foo?'} - , {'name': 'foo', 'description': 'Foo!'} + docs = [ {'doc': {'name': 'foo', 'description': 'Foo.'}} + , {'doc': {'name': 'foo', 'description': 'Foo?'}} + , {'doc': {'name': 'foo', 'description': 'Foo!'}} ] sync_npm.consume_change_stream(self.change_stream(docs), self.db) @@ -60,10 +61,23 @@ def test_consumes_change_stream(self): assert package.emails == [] + def test_not_afraid_to_delete_docs(self): + docs = [ {'doc': {'name': 'foo', 'description': 'Foo.'}} + , {'doc': {'name': 'foo', 'description': 'Foo?'}} + , {'deleted': True, 'id': 'foo'} + ] + sync_npm.consume_change_stream(self.change_stream(docs), self.db) + assert self.db.one('select * from packages') is None + + + # TODO Test for packages linked to teams when we get there. + + def test_picks_up_with_last_seq(self): - docs = [ {'name': 'foo', 'description': 'Foo.'} - , {'name': 'foo', 'description': 'See alice?', 'maintainers': [{'email': 'alice'}]} - , {'name': 'foo', 'description': "No, I don't see alice!"} + docs = [ {'doc': {'name': 'foo', 'description': 'Foo.'}} + , {'doc': {'name': 'foo', 'description': 'See alice?', + 'maintainers': [{'email': 'alice'}]}} + , {'doc': {'name': 'foo', 'description': "No, I don't see alice!"}} ] self.db.run('update worker_coordination set npm_last_seq=2') sync_npm.consume_change_stream(self.change_stream(docs), self.db) @@ -74,7 +88,7 @@ def test_picks_up_with_last_seq(self): def test_sets_last_seq(self): - docs = [{'name': 'foo', 'description': 'Foo.'}] * 13 + docs = [{'doc': {'name': 'foo', 'description': 'Foo.'}}] * 13 assert self.db.one('select npm_last_seq from worker_coordination') == -1 sync_npm.consume_change_stream(self.change_stream(docs), self.db) assert self.db.one('select npm_last_seq from worker_coordination') == 12 From dcec6cb6be80adaaee0fb8f14dbd9e8ff7526d7d Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Thu, 4 May 2017 18:12:03 -0400 Subject: [PATCH 04/11] Note required Postgres extensions in README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4723af6bcf..a2ef24b1f9 100644 --- a/README.md +++ b/README.md @@ -439,7 +439,8 @@ Local Database Setup For the best development experience, you need a local installation of [Postgres](https://www.postgresql.org/download/). The best version of Postgres to use is 9.6.2, because that's what we're using in production at Heroku. You -need at least 9.5 to support the features we depend on. +need at least 9.5 to support the features we depend on, along with the +`pg_stat_statments` and `pg_trgm` extensions. + Mac: use Homebrew: `brew install postgres` + Ubuntu: use Apt: `apt-get install postgresql postgresql-contrib libpq-dev` From bad23118bf11160e190a9d6c5d1c051a14117e41 Mon Sep 17 00:00:00 2001 From: Paul Kuruvilla Date: Fri, 5 May 2017 15:44:13 +0530 Subject: [PATCH 05/11] Specify postgres 9.5 on Travis --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 030099df09..428c916fe6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -3,7 +3,7 @@ language: python git: depth: 5 addons: - postgresql: 9.6 + postgresql: 9.5 firefox: latest-esr before_install: - git branch -vv | grep '^*' From 1cdd131a1145aaa860535fcc8fd1cf39dfb6da5c Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Fri, 5 May 2017 07:40:23 -0400 Subject: [PATCH 06/11] Use =0 to turn off npm sync logging --- defaults.env | 2 +- gratipay/application.py | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/defaults.env b/defaults.env index 49a76440ac..54925d4325 100644 --- a/defaults.env +++ b/defaults.env @@ -79,7 +79,7 @@ EMAIL_QUEUE_ALLOW_UP_TO=3 UPDATE_CTA_EVERY=300 CHECK_DB_EVERY=600 -CHECK_NPM_SYNC_EVERY=60 +CHECK_NPM_SYNC_EVERY=0 OPTIMIZELY_ID= INCLUDE_PIWIK=no SENTRY_DSN= diff --git a/gratipay/application.py b/gratipay/application.py index 829ae894cc..1c14539935 100644 --- a/gratipay/application.py +++ b/gratipay/application.py @@ -53,8 +53,7 @@ def install_periodic_jobs(self, website, env, db): cron(env.update_cta_every, lambda: utils.update_cta(website)) cron(env.check_db_every, db.self_check, True) cron(env.email_queue_flush_every, self.email_queue.flush, True) - if website.log_metrics: - cron(env.check_npm_sync_every, lambda: sync_npm.check(db), True) + cron(env.check_npm_sync_every, lambda: sync_npm.check(db)) def add_event(self, c, type, payload): From 1837e20bd546f18ac69510b8767b817f09163aaf Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Fri, 5 May 2017 07:41:38 -0400 Subject: [PATCH 07/11] Use a more canonical URL for the registry This is the one advertised in: https://github.com/npm/registry/blob/master/docs/follower.md --- gratipay/sync_npm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gratipay/sync_npm.py b/gratipay/sync_npm.py index 39e062b4e9..8e1b0f6e53 100644 --- a/gratipay/sync_npm.py +++ b/gratipay/sync_npm.py @@ -6,7 +6,7 @@ from couchdb import Database -REGISTRY_URL = 'https://skimdb.npmjs.com/registry' +REGISTRY_URL = 'https://replicate.npmjs.com/' def get_last_seq(db): From 1a0cd7a70276d36e560e39e9f60897ef42cc6854 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Fri, 5 May 2017 07:49:42 -0400 Subject: [PATCH 08/11] Update fixture to match --- .../py/fixtures/ConsumeChangeStreamTests.yml | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/tests/py/fixtures/ConsumeChangeStreamTests.yml b/tests/py/fixtures/ConsumeChangeStreamTests.yml index d1cb53ec09..2f47041696 100644 --- a/tests/py/fixtures/ConsumeChangeStreamTests.yml +++ b/tests/py/fixtures/ConsumeChangeStreamTests.yml @@ -3,15 +3,35 @@ interactions: body: null headers: {} method: GET - uri: https://skimdb.npmjs.com:443/registry + uri: https://replicate.npmjs.com:443/ response: body: string: !!binary | - H4sIAAAAAAAEA22RzW7DIBCE732MPftg/sEvgyisXVQbp4CjtlHevTh1JKvqdZaZnf24QXi1yS0I - A2ScYqn5CzoIq7d+3VKFgXPdE/YrBZyfstKcd7BdgqtoC37AQIwSXOgOLlueDq3vwK/Lxflq85ZS - TBMMo5sLtrxY3m2J320zZUwbRbRUHaz1DTMMN2jB7pgT1WsmCKPq3nwnvbmE0Ua0entS2X1jnP9E - tu3x2jRyeo6fFXNyc1PP4TGV6pJv7avL1db4AEO4NlwqSgQxkjKxA9rbj2teXLVXzCWuCQb5uHaJ - tWKw/6I5YLTxExnttWgctxjaD1BJQmBcmjF4JGNPzag5E1IH9MaJAPeXH2Iq25KxAQAA + H4sIAAAAAAAEA22RzW7DIBCE732MPfsAGPPjl0EE1imqjVPAUdso79516kpR1SOz7MzwcYN4ctkv + CCMUPKfayid0ENfgwrrlBqPUPZP8R4o4/8pGyL6D7RJ9Q1fxHUZuFBc96+CylfOh0Smsy8WH5sqW + c8pnGCc/VyS/VN9cTV+ULJiQdlCc6w7W9ooFxhuQsT/m3DBllRgMv9Pek26lpBqMUczuVPe9Kc1/ + LCk9XUnjT9fxo2HJft5rP5mnXJvPgdo3X5pr6QGGS2MtU1xbbW0/mB3Q3n5ay+Kbu2Kpac0wqsdr + l9QaRvcvmgMGjQ9kg7HKEsctRfqBXjMUSg1KY7CRCyl8CIKwmt5G5TXcX74Bo/OKf7EBAAA= + headers: + cache-control: [must-revalidate] + content-encoding: [gzip] + content-type: [application/json] + strict-transport-security: [max-age=2592000000; includeSubDomains; preload;] + transfer-encoding: [chunked] + status: {code: 200, message: OK} +- request: + body: null + headers: {} + method: GET + uri: https://replicate.npmjs.com:443/ + response: + body: + string: !!binary | + H4sIAAAAAAAEA22RzW7DIBCE732MPfsAGPPjl0EE1imqjVPAUdso79516kpR1SOz7MzwcYN4ctkv + CCMUPKfayid0ENfgwrrlBqPUPZP8R4o4/8pGyL6D7RJ9Q1fxHUZuFBc96+CylfOh0Smsy8WH5sqW + c8pnGCc/VyS/VN9cTV+ULJiQdlCc6w7W9ooFxhuQsT/m3DBllRgMv9Pek26lpBqMUczuVPe9Kc1/ + LCk9XUnjT9fxo2HJft5rP5mnXJvPgdo3X5pr6QGGS2MtU1xbbW0/mB3Q3n5ay+Kbu2Kpac0wqsdr + l9QaRvcvmgMGjQ9kg7HKEsctRfqBXjMUSg1KY7CRCyl8CIKwmt5G5TXcX74Bo/OKf7EBAAA= headers: cache-control: [must-revalidate] content-encoding: [gzip] From 631dfc9b7c8bd9b0c8851845e073a792c6d53810 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Fri, 5 May 2017 08:21:14 -0400 Subject: [PATCH 09/11] Respond to review --- gratipay/sync_npm.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/gratipay/sync_npm.py b/gratipay/sync_npm.py index 8e1b0f6e53..dc4b13f145 100644 --- a/gratipay/sync_npm.py +++ b/gratipay/sync_npm.py @@ -49,22 +49,25 @@ def consume_change_stream(change_stream, db): log("Picking up with npm sync at {}.".format(last_seq)) with db.get_connection() as connection: for change in change_stream(last_seq): + + # Decide what to do. if change.get('deleted'): - # Hack to work around conflation of design docs and packages in updates - op, doc = delete, {'name': change['id']} + op, arg = delete, change['id'] else: - op, doc = upsert, change['doc'] - processed = process_doc(doc) - if not processed: - continue + processed_doc = process_doc(change['doc']) + if not processed_doc: + continue + op, arg = upsert, processed_doc + + # Do it. cursor = connection.cursor() - op(cursor, processed) + op(cursor, arg) cursor.run('UPDATE worker_coordination SET npm_last_seq=%(seq)s', change) connection.commit() -def delete(cursor, processed): - cursor.run("DELETE FROM packages WHERE package_manager='npm' AND name=%(name)s", processed) +def delete(cursor, name): + cursor.run("DELETE FROM packages WHERE package_manager='npm' AND name=%s", (name,)) def upsert(cursor, processed): From af41409c5bea0f3cea3afbd804e64c83ef97c693 Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Fri, 5 May 2017 08:35:25 -0400 Subject: [PATCH 10/11] Simplify definition of consume_change_stream --- gratipay/cli/sync_npm.py | 5 ++++- gratipay/sync_npm.py | 8 +++----- tests/py/test_sync_npm.py | 3 ++- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/gratipay/cli/sync_npm.py b/gratipay/cli/sync_npm.py index e2fa597506..337c56a79c 100644 --- a/gratipay/cli/sync_npm.py +++ b/gratipay/cli/sync_npm.py @@ -25,7 +25,10 @@ def main(): db = wireup.db(env) while 1: with sentry.teller(env): - consume_change_stream(production_change_stream, db) + last_seq = get_last_seq(db) + log("Picking up with npm sync at {}.".format(last_seq)) + stream = production_change_stream(last_seq) + consume_change_stream(stream, db) try: last_seq = get_last_seq(db) sleep_for = 60 diff --git a/gratipay/sync_npm.py b/gratipay/sync_npm.py index dc4b13f145..231be0b3f2 100644 --- a/gratipay/sync_npm.py +++ b/gratipay/sync_npm.py @@ -35,8 +35,8 @@ def process_doc(doc): return {'name': name, 'description': description, 'emails': sorted(set(emails))} -def consume_change_stream(change_stream, db): - """Given a function similar to :py:func:`production_change_stream` and a +def consume_change_stream(stream, db): + """Given an iterable of CouchDB change notifications and a :py:class:`~GratipayDB`, read from the stream and write to the db. The npm registry is a CouchDB app, which means we get a change stream from @@ -45,10 +45,8 @@ def consume_change_stream(change_stream, db): database, and write as we read. """ - last_seq = get_last_seq(db) - log("Picking up with npm sync at {}.".format(last_seq)) with db.get_connection() as connection: - for change in change_stream(last_seq): + for change in stream: # Decide what to do. if change.get('deleted'): diff --git a/tests/py/test_sync_npm.py b/tests/py/test_sync_npm.py index dfcbc6e2d9..528a5fead7 100644 --- a/tests/py/test_sync_npm.py +++ b/tests/py/test_sync_npm.py @@ -40,7 +40,8 @@ def change_stream(seq): if i < seq: continue change['seq'] = i yield change - return change_stream + last_seq = sync_npm.get_last_seq(self.db) + return change_stream(last_seq) def test_packages_starts_empty(self): From a997d38014ac78b2e1e4c3400b8bc7d5f25d794a Mon Sep 17 00:00:00 2001 From: Chad Whitacre Date: Fri, 5 May 2017 08:44:58 -0400 Subject: [PATCH 11/11] Pyflakes nit --- gratipay/sync_npm.py | 1 - 1 file changed, 1 deletion(-) diff --git a/gratipay/sync_npm.py b/gratipay/sync_npm.py index 231be0b3f2..5eb9a027a1 100644 --- a/gratipay/sync_npm.py +++ b/gratipay/sync_npm.py @@ -2,7 +2,6 @@ from __future__ import absolute_import, division, print_function, unicode_literals import requests -from aspen import log from couchdb import Database