nsq2mariadb 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lars Wallenborn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: nsq2mariadb
3
+ Version: 0.1.2
4
+ Summary: generic NSQ → MariaDB transporter with per-topic Python mapper classes
5
+ Home-page: https://github.com/larsborn/nsq2mariadb
6
+ Author: Lars Wallenborn
7
+ Project-URL: Bug Tracker, https://github.com/larsborn/nsq2mariadb/issues
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: pynsq
15
+ Requires-Dist: pymysql
16
+ Dynamic: author
17
+ Dynamic: classifier
18
+ Dynamic: description
19
+ Dynamic: description-content-type
20
+ Dynamic: home-page
21
+ Dynamic: license-file
22
+ Dynamic: project-url
23
+ Dynamic: requires-dist
24
+ Dynamic: requires-python
25
+ Dynamic: summary
26
+
27
+ # nsq2mariadb
28
+
29
+ Generic transporter for moving JSON messages from [NSQ](https://nsq.io/) topics into
30
+ [MariaDB](https://mariadb.org/) tables. You write one Python `Mapper` class per
31
+ topic; the framework handles the NSQ subscription, schema bootstrap, and
32
+ transactional inserts.
33
+
34
+ The shape mirrors [`nsq2arangodb`](https://github.com/larsborn/nsq2arangodb) —
35
+ but because MariaDB is schema-full, the per-topic schema and JSON-to-row
36
+ translation has to live in code rather than configuration.
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ pip install git+https://github.com/larsborn/nsq2mariadb.git@v0.1.0
42
+ ```
43
+
44
+ Or for local development:
45
+
46
+ ```bash
47
+ git clone https://github.com/larsborn/nsq2mariadb.git
48
+ cd nsq2mariadb
49
+ python3 -m venv .venv
50
+ source .venv/bin/activate
51
+ pip install -U pip
52
+ pip install -e .
53
+ ```
54
+
55
+ Requires Python 3.9+. Dependencies: `pynsq`, `pymysql`.
56
+
57
+ ## Usage
58
+
59
+ For each NSQ topic you want to consume, subclass `Mapper`:
60
+
61
+ ```python
62
+ from nsq2mariadb import Mapper
63
+
64
+ class OrderMapper(Mapper):
65
+ topic = "orders"
66
+ schema_sql = """
67
+ CREATE TABLE IF NOT EXISTS `order` (
68
+ id INT PRIMARY KEY,
69
+ customer VARCHAR(255) NOT NULL,
70
+ inserted_at DATETIME NOT NULL
71
+ );
72
+ CREATE TABLE IF NOT EXISTS order_item (
73
+ order_id INT NOT NULL,
74
+ position SMALLINT NOT NULL,
75
+ sku VARCHAR(32) NOT NULL,
76
+ PRIMARY KEY (order_id, position),
77
+ FOREIGN KEY (order_id) REFERENCES `order`(id) ON DELETE CASCADE
78
+ );
79
+ """
80
+
81
+ def transform(self, doc):
82
+ yield "order", {
83
+ "id": doc["id"],
84
+ "customer": doc["customer"],
85
+ "inserted_at": doc["inserted_at"],
86
+ }
87
+ for i, sku in enumerate(doc.get("items", [])):
88
+ yield "order_item", {"order_id": doc["id"], "position": i, "sku": sku}
89
+ ```
90
+
91
+ Then wire it into the runner:
92
+
93
+ ```python
94
+ import logging
95
+ from nsq2mariadb import MariaDBConfig, Nsq2MariaDB, NsqConfig
96
+
97
+ logging.basicConfig(level=logging.INFO)
98
+
99
+ runner = Nsq2MariaDB(
100
+ logger=logging.getLogger("nsq2mariadb"),
101
+ mariadb_config=MariaDBConfig(
102
+ host="mariadb", port=3306,
103
+ user="orders", password="secret", database="orders",
104
+ ),
105
+ nsq_config=NsqConfig(
106
+ address="nsq-nsqd-1", port=4150,
107
+ channel="nsq2mariadb",
108
+ ),
109
+ mappers=[OrderMapper()],
110
+ )
111
+ runner.run()
112
+ ```
113
+
114
+ On startup the framework opens one pymysql connection, executes every mapper's
115
+ `schema_sql` with `CREATE TABLE IF NOT EXISTS` semantics, and subscribes an
116
+ `nsq.Reader` per mapper. Each NSQ message is decoded, fanned out through the
117
+ mapper's `transform()`, and inserted in a single transaction with parameterized
118
+ `INSERT IGNORE` statements (so re-published messages are idempotent as long as
119
+ your primary key reflects content identity).
120
+
121
+ ## Error handling
122
+
123
+ | Failure mode | Behavior |
124
+ |------------------------------------|-------------------------------------------------------------|
125
+ | JSON decode error | Logged with traceback, message FIN'd (dropped — won't fix). |
126
+ | `pymysql.MySQLError` during insert | Transaction rolled back, message FIN'd, traceback logged. |
127
+ | Connection drop | pymysql raises, propagates, process exits — relies on container restart. |
128
+
129
+ Schema mismatches (unknown column, missing table, FK violation) are programmer
130
+ or schema bugs that loop forever if requeued, so we drop them loudly. Tune your
131
+ log shipping accordingly.
132
+
133
+ ## Multiple topics per process
134
+
135
+ Pass several mappers to one `Nsq2MariaDB` instance — pynsq supports multiple
136
+ `Reader`s in one IOLoop. They share the database connection but each runs an
137
+ independent NSQ subscription. Useful when a single project produces several
138
+ related topics (e.g. `entries` + `runs`) that you want to land in the same DB.
139
+
140
+ ## Releasing
141
+
142
+ Releases are auto-published to [PyPI](https://pypi.org/project/nsq2mariadb/)
143
+ by `.github/workflows/publish.yml` on every `v*` tag, via PyPI's Trusted
144
+ Publishers (OIDC — no API token stored in the repo).
145
+
146
+ To cut a release:
147
+
148
+ 1. Bump the version in `setup.py`.
149
+ 2. Commit and push to `main`.
150
+ 3. Tag and push: `git tag v0.1.3 && git push origin v0.1.3`.
151
+
152
+ The workflow builds an sdist + wheel and publishes them under the
153
+ `pypi` GitHub environment.
154
+
155
+ ## License
156
+
157
+ MIT.
@@ -0,0 +1,131 @@
1
+ # nsq2mariadb
2
+
3
+ Generic transporter for moving JSON messages from [NSQ](https://nsq.io/) topics into
4
+ [MariaDB](https://mariadb.org/) tables. You write one Python `Mapper` class per
5
+ topic; the framework handles the NSQ subscription, schema bootstrap, and
6
+ transactional inserts.
7
+
8
+ The shape mirrors [`nsq2arangodb`](https://github.com/larsborn/nsq2arangodb) —
9
+ but because MariaDB is schema-full, the per-topic schema and JSON-to-row
10
+ translation has to live in code rather than configuration.
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ pip install git+https://github.com/larsborn/nsq2mariadb.git@v0.1.0
16
+ ```
17
+
18
+ Or for local development:
19
+
20
+ ```bash
21
+ git clone https://github.com/larsborn/nsq2mariadb.git
22
+ cd nsq2mariadb
23
+ python3 -m venv .venv
24
+ source .venv/bin/activate
25
+ pip install -U pip
26
+ pip install -e .
27
+ ```
28
+
29
+ Requires Python 3.9+. Dependencies: `pynsq`, `pymysql`.
30
+
31
+ ## Usage
32
+
33
+ For each NSQ topic you want to consume, subclass `Mapper`:
34
+
35
+ ```python
36
+ from nsq2mariadb import Mapper
37
+
38
+ class OrderMapper(Mapper):
39
+ topic = "orders"
40
+ schema_sql = """
41
+ CREATE TABLE IF NOT EXISTS `order` (
42
+ id INT PRIMARY KEY,
43
+ customer VARCHAR(255) NOT NULL,
44
+ inserted_at DATETIME NOT NULL
45
+ );
46
+ CREATE TABLE IF NOT EXISTS order_item (
47
+ order_id INT NOT NULL,
48
+ position SMALLINT NOT NULL,
49
+ sku VARCHAR(32) NOT NULL,
50
+ PRIMARY KEY (order_id, position),
51
+ FOREIGN KEY (order_id) REFERENCES `order`(id) ON DELETE CASCADE
52
+ );
53
+ """
54
+
55
+ def transform(self, doc):
56
+ yield "order", {
57
+ "id": doc["id"],
58
+ "customer": doc["customer"],
59
+ "inserted_at": doc["inserted_at"],
60
+ }
61
+ for i, sku in enumerate(doc.get("items", [])):
62
+ yield "order_item", {"order_id": doc["id"], "position": i, "sku": sku}
63
+ ```
64
+
65
+ Then wire it into the runner:
66
+
67
+ ```python
68
+ import logging
69
+ from nsq2mariadb import MariaDBConfig, Nsq2MariaDB, NsqConfig
70
+
71
+ logging.basicConfig(level=logging.INFO)
72
+
73
+ runner = Nsq2MariaDB(
74
+ logger=logging.getLogger("nsq2mariadb"),
75
+ mariadb_config=MariaDBConfig(
76
+ host="mariadb", port=3306,
77
+ user="orders", password="secret", database="orders",
78
+ ),
79
+ nsq_config=NsqConfig(
80
+ address="nsq-nsqd-1", port=4150,
81
+ channel="nsq2mariadb",
82
+ ),
83
+ mappers=[OrderMapper()],
84
+ )
85
+ runner.run()
86
+ ```
87
+
88
+ On startup the framework opens one pymysql connection, executes every mapper's
89
+ `schema_sql` with `CREATE TABLE IF NOT EXISTS` semantics, and subscribes an
90
+ `nsq.Reader` per mapper. Each NSQ message is decoded, fanned out through the
91
+ mapper's `transform()`, and inserted in a single transaction with parameterized
92
+ `INSERT IGNORE` statements (so re-published messages are idempotent as long as
93
+ your primary key reflects content identity).
94
+
95
+ ## Error handling
96
+
97
+ | Failure mode | Behavior |
98
+ |------------------------------------|-------------------------------------------------------------|
99
+ | JSON decode error | Logged with traceback, message FIN'd (dropped — won't fix). |
100
+ | `pymysql.MySQLError` during insert | Transaction rolled back, message FIN'd, traceback logged. |
101
+ | Connection drop | pymysql raises, propagates, process exits — relies on container restart. |
102
+
103
+ Schema mismatches (unknown column, missing table, FK violation) are programmer
104
+ or schema bugs that loop forever if requeued, so we drop them loudly. Tune your
105
+ log shipping accordingly.
106
+
107
+ ## Multiple topics per process
108
+
109
+ Pass several mappers to one `Nsq2MariaDB` instance — pynsq supports multiple
110
+ `Reader`s in one IOLoop. They share the database connection but each runs an
111
+ independent NSQ subscription. Useful when a single project produces several
112
+ related topics (e.g. `entries` + `runs`) that you want to land in the same DB.
113
+
114
+ ## Releasing
115
+
116
+ Releases are auto-published to [PyPI](https://pypi.org/project/nsq2mariadb/)
117
+ by `.github/workflows/publish.yml` on every `v*` tag, via PyPI's Trusted
118
+ Publishers (OIDC — no API token stored in the repo).
119
+
120
+ To cut a release:
121
+
122
+ 1. Bump the version in `setup.py`.
123
+ 2. Commit and push to `main`.
124
+ 3. Tag and push: `git tag v0.1.3 && git push origin v0.1.3`.
125
+
126
+ The workflow builds an sdist + wheel and publishes them under the
127
+ `pypi` GitHub environment.
128
+
129
+ ## License
130
+
131
+ MIT.
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ from nsq2mariadb.nsq2mariadb import MariaDBConfig, Mapper, Nsq2MariaDB, NsqConfig
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """Generic NSQ -> MariaDB transporter.
4
+
5
+ Operators subscribe one or more `Mapper` subclasses (one per NSQ topic) to a
6
+ running `Nsq2MariaDB` instance. Each mapper declares its target schema (DDL
7
+ applied with CREATE TABLE IF NOT EXISTS on startup) and a `transform(doc)`
8
+ method that yields `(table_name, row_dict)` tuples. The framework wraps every
9
+ NSQ message in a single MariaDB transaction across all yielded rows and uses
10
+ parameterized `INSERT IGNORE` for idempotency.
11
+ """
12
+ import json
13
+ import logging
14
+ from abc import ABC, abstractmethod
15
+ from dataclasses import dataclass
16
+ from typing import Iterable, List, Sequence, Tuple
17
+
18
+ import nsq
19
+ import pymysql
20
+
21
+
22
+ @dataclass
23
+ class MariaDBConfig:
24
+ host: str
25
+ port: int
26
+ user: str
27
+ password: str
28
+ database: str
29
+ charset: str = "utf8mb4"
30
+
31
+
32
+ @dataclass
33
+ class NsqConfig:
34
+ address: str
35
+ port: int
36
+ channel: str
37
+ max_in_flight: int = 1
38
+
39
+
40
+ class Mapper(ABC):
41
+ """Subclass per NSQ topic.
42
+
43
+ Set `topic` to the NSQ topic name and `schema_sql` to one or more
44
+ `CREATE TABLE IF NOT EXISTS` statements separated by semicolons.
45
+ Implement `transform()` to translate a decoded JSON message into rows.
46
+ """
47
+
48
+ topic: str = ""
49
+ schema_sql: str = ""
50
+
51
+ @abstractmethod
52
+ def transform(self, doc: dict) -> Iterable[Tuple[str, dict]]:
53
+ """Yield `(table_name, row_dict)` per row this message should insert."""
54
+
55
+
56
+ def _split_statements(sql: str) -> List[str]:
57
+ """Split a multi-statement SQL string on `;` and drop empty fragments."""
58
+ return [stmt.strip() for stmt in sql.split(";") if stmt.strip()]
59
+
60
+
61
+ def _build_insert(table: str, row: dict) -> Tuple[str, Sequence]:
62
+ """Build a parameterized `INSERT IGNORE` statement and its values tuple."""
63
+ if not row:
64
+ raise ValueError(f"refusing to insert empty row into {table!r}")
65
+ columns = list(row.keys())
66
+ col_list = ",".join(f"`{c}`" for c in columns)
67
+ placeholders = ",".join(["%s"] * len(columns))
68
+ sql = f"INSERT IGNORE INTO `{table}` ({col_list}) VALUES ({placeholders})"
69
+ return sql, tuple(row[c] for c in columns)
70
+
71
+
72
+ class Nsq2MariaDB:
73
+ """Run one or more mappers against a single MariaDB connection."""
74
+
75
+ def __init__(
76
+ self,
77
+ logger: logging.Logger,
78
+ mariadb_config: MariaDBConfig,
79
+ nsq_config: NsqConfig,
80
+ mappers: Sequence[Mapper],
81
+ connection=None,
82
+ ):
83
+ if not mappers:
84
+ raise ValueError("at least one Mapper is required")
85
+ self._logger = logger
86
+ self._mariadb_config = mariadb_config
87
+ self._nsq_config = nsq_config
88
+ self._mappers = list(mappers)
89
+ self._conn = connection if connection is not None else self._open_connection()
90
+ self._apply_schemas()
91
+ self._register_readers()
92
+
93
+ def run(self) -> None:
94
+ """Enter the NSQ IOLoop. Returns when nsq.run() returns."""
95
+ nsq.run()
96
+
97
+ def _open_connection(self):
98
+ cfg = self._mariadb_config
99
+ return pymysql.connect(
100
+ host=cfg.host,
101
+ port=cfg.port,
102
+ user=cfg.user,
103
+ password=cfg.password,
104
+ database=cfg.database,
105
+ charset=cfg.charset,
106
+ autocommit=False,
107
+ )
108
+
109
+ def _apply_schemas(self) -> None:
110
+ for mapper in self._mappers:
111
+ statements = _split_statements(mapper.schema_sql)
112
+ if not statements:
113
+ continue
114
+ self._logger.info(
115
+ f"applying {len(statements)} schema statement(s) for topic {mapper.topic!r}"
116
+ )
117
+ with self._conn.cursor() as cur:
118
+ for stmt in statements:
119
+ cur.execute(stmt)
120
+ self._conn.commit()
121
+
122
+ def _register_readers(self) -> None:
123
+ for mapper in self._mappers:
124
+ nsq.Reader(
125
+ message_handler=self._make_handler(mapper),
126
+ nsqd_tcp_addresses=[f"{self._nsq_config.address}:{self._nsq_config.port}"],
127
+ topic=mapper.topic,
128
+ channel=self._nsq_config.channel,
129
+ max_in_flight=self._nsq_config.max_in_flight,
130
+ )
131
+ self._logger.info(
132
+ f"subscribed to topic {mapper.topic!r} on channel {self._nsq_config.channel!r}"
133
+ )
134
+
135
+ def _make_handler(self, mapper: Mapper):
136
+ def handler(message) -> bool:
137
+ return self._handle_message(mapper, message)
138
+
139
+ return handler
140
+
141
+ def _handle_message(self, mapper: Mapper, message) -> bool:
142
+ try:
143
+ doc = json.loads(message.body.decode("utf-8"))
144
+ except (UnicodeDecodeError, json.JSONDecodeError):
145
+ self._logger.exception(
146
+ f"failed to decode message on topic {mapper.topic!r}; dropping"
147
+ )
148
+ return True # FIN; broken JSON won't fix itself on retry
149
+ try:
150
+ with self._conn.cursor() as cur:
151
+ for table, row in mapper.transform(doc):
152
+ sql, params = _build_insert(table, row)
153
+ cur.execute(sql, params)
154
+ self._conn.commit()
155
+ except pymysql.MySQLError:
156
+ self._conn.rollback()
157
+ self._logger.exception(
158
+ f"database error handling message on topic {mapper.topic!r}; dropping"
159
+ )
160
+ return True # FIN; matches nsq2arangodb behavior — programmer/data bugs
161
+ # won't fix themselves on retry, so don't loop forever
162
+ return True
@@ -0,0 +1,157 @@
1
+ Metadata-Version: 2.4
2
+ Name: nsq2mariadb
3
+ Version: 0.1.2
4
+ Summary: generic NSQ → MariaDB transporter with per-topic Python mapper classes
5
+ Home-page: https://github.com/larsborn/nsq2mariadb
6
+ Author: Lars Wallenborn
7
+ Project-URL: Bug Tracker, https://github.com/larsborn/nsq2mariadb/issues
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.9
12
+ Description-Content-Type: text/markdown
13
+ License-File: LICENSE
14
+ Requires-Dist: pynsq
15
+ Requires-Dist: pymysql
16
+ Dynamic: author
17
+ Dynamic: classifier
18
+ Dynamic: description
19
+ Dynamic: description-content-type
20
+ Dynamic: home-page
21
+ Dynamic: license-file
22
+ Dynamic: project-url
23
+ Dynamic: requires-dist
24
+ Dynamic: requires-python
25
+ Dynamic: summary
26
+
27
+ # nsq2mariadb
28
+
29
+ Generic transporter for moving JSON messages from [NSQ](https://nsq.io/) topics into
30
+ [MariaDB](https://mariadb.org/) tables. You write one Python `Mapper` class per
31
+ topic; the framework handles the NSQ subscription, schema bootstrap, and
32
+ transactional inserts.
33
+
34
+ The shape mirrors [`nsq2arangodb`](https://github.com/larsborn/nsq2arangodb) —
35
+ but because MariaDB is schema-full, the per-topic schema and JSON-to-row
36
+ translation has to live in code rather than configuration.
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ pip install git+https://github.com/larsborn/nsq2mariadb.git@v0.1.0
42
+ ```
43
+
44
+ Or for local development:
45
+
46
+ ```bash
47
+ git clone https://github.com/larsborn/nsq2mariadb.git
48
+ cd nsq2mariadb
49
+ python3 -m venv .venv
50
+ source .venv/bin/activate
51
+ pip install -U pip
52
+ pip install -e .
53
+ ```
54
+
55
+ Requires Python 3.9+. Dependencies: `pynsq`, `pymysql`.
56
+
57
+ ## Usage
58
+
59
+ For each NSQ topic you want to consume, subclass `Mapper`:
60
+
61
+ ```python
62
+ from nsq2mariadb import Mapper
63
+
64
+ class OrderMapper(Mapper):
65
+ topic = "orders"
66
+ schema_sql = """
67
+ CREATE TABLE IF NOT EXISTS `order` (
68
+ id INT PRIMARY KEY,
69
+ customer VARCHAR(255) NOT NULL,
70
+ inserted_at DATETIME NOT NULL
71
+ );
72
+ CREATE TABLE IF NOT EXISTS order_item (
73
+ order_id INT NOT NULL,
74
+ position SMALLINT NOT NULL,
75
+ sku VARCHAR(32) NOT NULL,
76
+ PRIMARY KEY (order_id, position),
77
+ FOREIGN KEY (order_id) REFERENCES `order`(id) ON DELETE CASCADE
78
+ );
79
+ """
80
+
81
+ def transform(self, doc):
82
+ yield "order", {
83
+ "id": doc["id"],
84
+ "customer": doc["customer"],
85
+ "inserted_at": doc["inserted_at"],
86
+ }
87
+ for i, sku in enumerate(doc.get("items", [])):
88
+ yield "order_item", {"order_id": doc["id"], "position": i, "sku": sku}
89
+ ```
90
+
91
+ Then wire it into the runner:
92
+
93
+ ```python
94
+ import logging
95
+ from nsq2mariadb import MariaDBConfig, Nsq2MariaDB, NsqConfig
96
+
97
+ logging.basicConfig(level=logging.INFO)
98
+
99
+ runner = Nsq2MariaDB(
100
+ logger=logging.getLogger("nsq2mariadb"),
101
+ mariadb_config=MariaDBConfig(
102
+ host="mariadb", port=3306,
103
+ user="orders", password="secret", database="orders",
104
+ ),
105
+ nsq_config=NsqConfig(
106
+ address="nsq-nsqd-1", port=4150,
107
+ channel="nsq2mariadb",
108
+ ),
109
+ mappers=[OrderMapper()],
110
+ )
111
+ runner.run()
112
+ ```
113
+
114
+ On startup the framework opens one pymysql connection, executes every mapper's
115
+ `schema_sql` with `CREATE TABLE IF NOT EXISTS` semantics, and subscribes an
116
+ `nsq.Reader` per mapper. Each NSQ message is decoded, fanned out through the
117
+ mapper's `transform()`, and inserted in a single transaction with parameterized
118
+ `INSERT IGNORE` statements (so re-published messages are idempotent as long as
119
+ your primary key reflects content identity).
120
+
121
+ ## Error handling
122
+
123
+ | Failure mode | Behavior |
124
+ |------------------------------------|-------------------------------------------------------------|
125
+ | JSON decode error | Logged with traceback, message FIN'd (dropped — won't fix). |
126
+ | `pymysql.MySQLError` during insert | Transaction rolled back, message FIN'd, traceback logged. |
127
+ | Connection drop | pymysql raises, propagates, process exits — relies on container restart. |
128
+
129
+ Schema mismatches (unknown column, missing table, FK violation) are programmer
130
+ or schema bugs that loop forever if requeued, so we drop them loudly. Tune your
131
+ log shipping accordingly.
132
+
133
+ ## Multiple topics per process
134
+
135
+ Pass several mappers to one `Nsq2MariaDB` instance — pynsq supports multiple
136
+ `Reader`s in one IOLoop. They share the database connection but each runs an
137
+ independent NSQ subscription. Useful when a single project produces several
138
+ related topics (e.g. `entries` + `runs`) that you want to land in the same DB.
139
+
140
+ ## Releasing
141
+
142
+ Releases are auto-published to [PyPI](https://pypi.org/project/nsq2mariadb/)
143
+ by `.github/workflows/publish.yml` on every `v*` tag, via PyPI's Trusted
144
+ Publishers (OIDC — no API token stored in the repo).
145
+
146
+ To cut a release:
147
+
148
+ 1. Bump the version in `setup.py`.
149
+ 2. Commit and push to `main`.
150
+ 3. Tag and push: `git tag v0.1.3 && git push origin v0.1.3`.
151
+
152
+ The workflow builds an sdist + wheel and publishes them under the
153
+ `pypi` GitHub environment.
154
+
155
+ ## License
156
+
157
+ MIT.
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ setup.cfg
5
+ setup.py
6
+ nsq2mariadb/__init__.py
7
+ nsq2mariadb/nsq2mariadb.py
8
+ nsq2mariadb.egg-info/PKG-INFO
9
+ nsq2mariadb.egg-info/SOURCES.txt
10
+ nsq2mariadb.egg-info/dependency_links.txt
11
+ nsq2mariadb.egg-info/requires.txt
12
+ nsq2mariadb.egg-info/top_level.txt
13
+ tests/test_nsq2mariadb.py
@@ -0,0 +1,2 @@
1
+ pynsq
2
+ pymysql
@@ -0,0 +1 @@
1
+ nsq2mariadb
@@ -0,0 +1,3 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,7 @@
1
+ [metadata]
2
+ license_files = LICENSE
3
+
4
+ [egg_info]
5
+ tag_build =
6
+ tag_date = 0
7
+
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ import setuptools
4
+
5
+ with open("README.md", "r", encoding="utf-8") as fp:
6
+ long_description = fp.read()
7
+
8
+ setuptools.setup(
9
+ name="nsq2mariadb",
10
+ version="0.1.2",
11
+ author="Lars Wallenborn",
12
+ description="generic NSQ → MariaDB transporter with per-topic Python mapper classes",
13
+ long_description=long_description,
14
+ long_description_content_type="text/markdown",
15
+ url="https://github.com/larsborn/nsq2mariadb",
16
+ project_urls={
17
+ "Bug Tracker": "https://github.com/larsborn/nsq2mariadb/issues",
18
+ },
19
+ classifiers=[
20
+ "Programming Language :: Python :: 3",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Operating System :: OS Independent",
23
+ ],
24
+ install_requires=[
25
+ "pynsq",
26
+ "pymysql",
27
+ ],
28
+ packages=setuptools.find_packages(exclude=["tests", "tests.*"]),
29
+ python_requires=">=3.9",
30
+ )
@@ -0,0 +1,219 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ import json
4
+ import logging
5
+ import unittest
6
+ from unittest.mock import MagicMock
7
+
8
+ import pymysql
9
+
10
+ from nsq2mariadb.nsq2mariadb import (
11
+ MariaDBConfig,
12
+ Mapper,
13
+ Nsq2MariaDB,
14
+ NsqConfig,
15
+ _build_insert,
16
+ _split_statements,
17
+ )
18
+
19
+
20
+ def _make_logger():
21
+ return logging.getLogger("test_nsq2mariadb")
22
+
23
+
24
+ def _silent_init(runner: Nsq2MariaDB) -> None:
25
+ """Stand-in for `_register_readers` — we don't want to touch real NSQ in tests."""
26
+ return None
27
+
28
+
29
+ class _StubCursor:
30
+ """Records every `execute(sql, params)` call and behaves as a context manager."""
31
+
32
+ def __init__(self):
33
+ self.calls = [] # list of (sql, params) tuples
34
+ self.raise_on_execute = None # set to an Exception instance to raise
35
+
36
+ def __enter__(self):
37
+ return self
38
+
39
+ def __exit__(self, *args):
40
+ return False
41
+
42
+ def execute(self, sql, params=None):
43
+ self.calls.append((sql, params))
44
+ if self.raise_on_execute is not None:
45
+ raise self.raise_on_execute
46
+
47
+
48
+ class _StubConnection:
49
+ def __init__(self):
50
+ self.cursor_obj = _StubCursor()
51
+ self.commits = 0
52
+ self.rollbacks = 0
53
+
54
+ def cursor(self):
55
+ return self.cursor_obj
56
+
57
+ def commit(self):
58
+ self.commits += 1
59
+
60
+ def rollback(self):
61
+ self.rollbacks += 1
62
+
63
+
64
+ class _FakeMessage:
65
+ def __init__(self, payload: dict):
66
+ self.body = json.dumps(payload).encode("utf-8")
67
+
68
+
69
+ class _SingleTableMapper(Mapper):
70
+ topic = "things"
71
+ schema_sql = "CREATE TABLE IF NOT EXISTS thing (`id` INT PRIMARY KEY, name VARCHAR(64))"
72
+
73
+ def transform(self, doc):
74
+ yield "thing", {"id": doc["id"], "name": doc["name"]}
75
+
76
+
77
+ class _MultiTableMapper(Mapper):
78
+ topic = "orders"
79
+ schema_sql = (
80
+ "CREATE TABLE IF NOT EXISTS `order` (`id` INT PRIMARY KEY);\n"
81
+ "CREATE TABLE IF NOT EXISTS order_item (order_id INT, position INT, sku VARCHAR(32));"
82
+ )
83
+
84
+ def transform(self, doc):
85
+ yield "order", {"id": doc["id"]}
86
+ for i, sku in enumerate(doc.get("items", [])):
87
+ yield "order_item", {"order_id": doc["id"], "position": i, "sku": sku}
88
+
89
+
90
+ def _build_runner(mapper, connection):
91
+ runner = Nsq2MariaDB.__new__(Nsq2MariaDB)
92
+ runner._logger = _make_logger()
93
+ runner._mariadb_config = MariaDBConfig(
94
+ host="localhost", port=3306, user="u", password="p", database="d"
95
+ )
96
+ runner._nsq_config = NsqConfig(address="127.0.0.1", port=4150, channel="test")
97
+ runner._mappers = [mapper]
98
+ runner._conn = connection
99
+ return runner
100
+
101
+
102
+ class TestSplitStatements(unittest.TestCase):
103
+ def test_splits_on_semicolons_and_drops_blanks(self):
104
+ sql = "CREATE TABLE a (x INT); ;\nCREATE TABLE b (y INT);\n\n"
105
+ self.assertEqual(
106
+ _split_statements(sql),
107
+ ["CREATE TABLE a (x INT)", "CREATE TABLE b (y INT)"],
108
+ )
109
+
110
+ def test_empty_string(self):
111
+ self.assertEqual(_split_statements(""), [])
112
+
113
+ def test_single_statement_no_trailing_semicolon(self):
114
+ self.assertEqual(_split_statements("SELECT 1"), ["SELECT 1"])
115
+
116
+
117
+ class TestBuildInsert(unittest.TestCase):
118
+ def test_single_column(self):
119
+ sql, params = _build_insert("t", {"a": 1})
120
+ self.assertEqual(sql, "INSERT IGNORE INTO `t` (`a`) VALUES (%s)")
121
+ self.assertEqual(params, (1,))
122
+
123
+ def test_multiple_columns_preserve_dict_order(self):
124
+ sql, params = _build_insert("zvg_entry", {"_key": "abc", "land_short": "by", "zvg_id": 7})
125
+ self.assertEqual(
126
+ sql,
127
+ "INSERT IGNORE INTO `zvg_entry` (`_key`,`land_short`,`zvg_id`) VALUES (%s,%s,%s)",
128
+ )
129
+ self.assertEqual(params, ("abc", "by", 7))
130
+
131
+ def test_empty_row_raises(self):
132
+ with self.assertRaises(ValueError):
133
+ _build_insert("t", {})
134
+
135
+
136
+ class TestHandleMessage(unittest.TestCase):
137
+ def test_single_table_inserts_and_commits(self):
138
+ conn = _StubConnection()
139
+ runner = _build_runner(_SingleTableMapper(), conn)
140
+
141
+ result = runner._handle_message(runner._mappers[0], _FakeMessage({"id": 42, "name": "x"}))
142
+
143
+ self.assertTrue(result)
144
+ self.assertEqual(conn.commits, 1)
145
+ self.assertEqual(conn.rollbacks, 0)
146
+ self.assertEqual(len(conn.cursor_obj.calls), 1)
147
+ sql, params = conn.cursor_obj.calls[0]
148
+ self.assertEqual(sql, "INSERT IGNORE INTO `thing` (`id`,`name`) VALUES (%s,%s)")
149
+ self.assertEqual(params, (42, "x"))
150
+
151
+ def test_multi_table_fanout_is_one_transaction(self):
152
+ conn = _StubConnection()
153
+ runner = _build_runner(_MultiTableMapper(), conn)
154
+
155
+ result = runner._handle_message(
156
+ runner._mappers[0],
157
+ _FakeMessage({"id": 7, "items": ["sku-A", "sku-B", "sku-C"]}),
158
+ )
159
+
160
+ self.assertTrue(result)
161
+ self.assertEqual(conn.commits, 1) # exactly one commit for the whole fanout
162
+ self.assertEqual(conn.rollbacks, 0)
163
+ self.assertEqual(len(conn.cursor_obj.calls), 4) # 1 order + 3 items
164
+ # First call is the parent row
165
+ sql, params = conn.cursor_obj.calls[0]
166
+ self.assertEqual(sql, "INSERT IGNORE INTO `order` (`id`) VALUES (%s)")
167
+ self.assertEqual(params, (7,))
168
+ # Subsequent calls are the items with preserved order
169
+ for i, sku in enumerate(["sku-A", "sku-B", "sku-C"]):
170
+ sql, params = conn.cursor_obj.calls[i + 1]
171
+ self.assertEqual(
172
+ sql,
173
+ "INSERT IGNORE INTO `order_item` (`order_id`,`position`,`sku`) VALUES (%s,%s,%s)",
174
+ )
175
+ self.assertEqual(params, (7, i, sku))
176
+
177
+ def test_db_error_rolls_back_and_drops_message(self):
178
+ conn = _StubConnection()
179
+ conn.cursor_obj.raise_on_execute = pymysql.MySQLError("table missing")
180
+ runner = _build_runner(_SingleTableMapper(), conn)
181
+
182
+ result = runner._handle_message(runner._mappers[0], _FakeMessage({"id": 1, "name": "x"}))
183
+
184
+ self.assertTrue(result) # FIN — do not requeue
185
+ self.assertEqual(conn.commits, 0)
186
+ self.assertEqual(conn.rollbacks, 1)
187
+
188
+ def test_json_decode_error_drops_message(self):
189
+ conn = _StubConnection()
190
+ runner = _build_runner(_SingleTableMapper(), conn)
191
+
192
+ bad_message = MagicMock()
193
+ bad_message.body = b"\xff\xfenot json"
194
+
195
+ result = runner._handle_message(runner._mappers[0], bad_message)
196
+
197
+ self.assertTrue(result)
198
+ self.assertEqual(conn.commits, 0)
199
+ self.assertEqual(conn.rollbacks, 0)
200
+ self.assertEqual(len(conn.cursor_obj.calls), 0)
201
+
202
+
203
+ class TestApplySchemas(unittest.TestCase):
204
+ def test_executes_each_statement_and_commits(self):
205
+ conn = _StubConnection()
206
+ runner = _build_runner(_MultiTableMapper(), conn)
207
+
208
+ # Re-apply manually (the constructor isn't called in our test fixture)
209
+ runner._apply_schemas()
210
+
211
+ # 2 statements in _MultiTableMapper.schema_sql
212
+ self.assertEqual(len(conn.cursor_obj.calls), 2)
213
+ self.assertIn("CREATE TABLE IF NOT EXISTS `order`", conn.cursor_obj.calls[0][0])
214
+ self.assertIn("CREATE TABLE IF NOT EXISTS order_item", conn.cursor_obj.calls[1][0])
215
+ self.assertEqual(conn.commits, 1)
216
+
217
+
218
+ if __name__ == "__main__":
219
+ unittest.main()