Skip to content

Build TargetDB on pfs.utils.database.db.DB - #156

Open
monodera wants to merge 7 commits into
mainfrom
tickets/OBSPROC-140
Open

Build TargetDB on pfs.utils.database.db.DB#156
monodera wants to merge 7 commits into
mainfrom
tickets/OBSPROC-140

Conversation

@monodera

@monodera monodera commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Replaces the hand-rolled TargetDB SQLAlchemy wrapper with a subclass of pfs.utils.database.db.DB, and standardises on psycopg3.

The old wrapper had accumulated real debt: reset_* passed raw strings to session.execute() and had been broken since SQLAlchemy 2.x, insert_by_copy/fetch_by_copy used psycopg2-only cursor APIs, and bulk inserts went through the ORM despite fluxstd/sky reaching millions of rows. DB supplies an engine cache, pool_pre_ping and COPY-based bulk insert; only what it lacks is implemented locally — a password and explicit dialect in the URL, update(), close(), and the dry_run= keyword behind --commit.

Two overrides are load-bearing

  • connect() returns None. DB.connect() hands back a pooled Connection, and every caller here and downstream discards it — inheriting it would leak one checked-out connection per call.
  • connection() begins its transaction eagerly. pandas.to_sql starts and commits a transaction of its own when given a connection not already in one, which would silently defeat the dry-run rollback and break the --commit default. tests/integration/test_dry_run.py proves the rollback against a real server, including the raw-cursor COPY path.

Other changes

  • psycopg3 only. normalize_drivername() rewrites the bare postgresql dialect at the single point every path passes through, so no deployed config file needs editing. psycopg2-binary dropped; requires-python raised to >=3.12 (CI already installs 3.12).
  • Password is now optional — omitted from the URL when absent so libpq resolves it via PGPASSWORD/~/.pgpass, letting a secret-free config be version controlled.
  • Alembic env.py builds its URL from TARGETDB_CONF, falling back to alembic.ini. config.set_main_option() is avoided because ConfigParser reads % as an interpolation marker.
  • alembic/pfsa-db01-gb-dev/ is now tracked, and every deployment directory has a credential-free alembic.ini.
  • The alembic env.py scripts are linted; the previous blanket exclusion of alembic/ was broader than its own rationale, which only covers the revision scripts.
  • Four scattered alembic note files consolidated into alembic/README.md.
  • requirements.txt removed — unreferenced, and drifted in both directions.

Two things reviewers should know

  • examples/data/proposals.csv had 8 rows with a blank group_id, a NOT NULL column. Verified experimentally that psycopg2 silently stored the literal string "NaN" there; COPY correctly rejects it as a null. The CSV is fixed, but the production proposal table likely still holds those 8 rows with group_id = 'NaN'.
  • alembic/local_test/'s revision chain is broken and predates this work: 80f8276e2ee7 names a down_revision that is not in the repository. Its integration test therefore asserts on the connection rather than the exit status.

Removed API

reset_all, reset_target, reset_fluxstd, reset_sky, insert_by_copy, fetch_by_copy, insert_mappings, rollback — none called from ets_target_database, ets_pointing or pfs_obsproc_planning_tools. insert() no longer takes return_defaults. connect, close, fetch_query, fetch_all and fetch_by_id are unchanged for downstream callers.

Testing

81 passed, 1 skipped. The pre-existing integration suite passes unmodified — the main evidence the migration is sound — plus new coverage for URL construction, dry-run rollback, connection-leak regressions, fetch_all column order, and TARGETDB_CONF. tbls diagram generation verified against a container, and SchemaCrawler verified end-to-end with a password-less config.

Downstream verification of ets_pointing / pfs_obsproc_planning_tools is out of scope; test 4 reproduces their call patterns in-repo.

🤖 Generated with Claude Code

monodera and others added 3 commits August 5, 2026 17:31
TargetDB was a hand-rolled SQLAlchemy wrapper carrying real debt: reset_*
passed raw strings to session.execute() and had been broken since SQLAlchemy
2.x, insert_by_copy/fetch_by_copy used psycopg2-only cursor APIs, and bulk
inserts went through the ORM despite fluxstd/sky reaching millions of rows.

Subclass pfs.utils.database.db.DB instead, which brings a process-wide engine
cache, pool_pre_ping and COPY-based bulk insert. Only what DB lacks is
implemented locally: a password and explicit dialect in the URL, update(),
close(), and the dry_run= keyword backing the CLI's --commit default.

Two overrides are load-bearing:

  * connect() returns None. DB.connect() hands back a pooled Connection and
    every caller here and downstream discards it, which would leak one
    checked-out connection per call.
  * connection() begins its transaction eagerly and rolls back under dry-run.
    The eager begin matters because pandas.to_sql starts *and commits* a
    transaction of its own when given a connection not already in one, which
    would otherwise defeat the rollback and silently break --commit.

Standardise on psycopg3. normalize_drivername() rewrites the bare "postgresql"
dialect to postgresql+psycopg at the single point every path passes through,
so no deployed config file needs editing. Drop psycopg2-binary and raise
requires-python to >=3.12 (pfs-utils needs it); CI already installs 3.12.

Make the password optional: when absent it is omitted from the URL and libpq
resolves it via PGPASSWORD or ~/.pgpass, so a config file with no secrets can
be version controlled.

Also in this change:

  * inserts drop DataFrame columns the table lacks. add_backref_values()
    merges whole reference tables in to resolve foreign keys, leaving columns
    like partner_name behind; bulk_insert_mappings() ignored unmapped keys and
    COPY does not.
  * fill in group_id for 8 rows of examples/data/proposals.csv. The column is
    NOT NULL and the values were blank; psycopg2 stored the literal string
    "NaN" there, which COPY correctly rejects as a null.
  * fix the tbls DSN. Replacing the scheme on the rendered string produced
    "postgres+psycopg://", which tbls cannot parse; swap the drivername on the
    URL object instead.
  * close three connection leaks where db.close() sat outside try/finally.
  * alembic env.py now builds its URL from the TOML file named by
    TARGETDB_CONF, falling back to alembic.ini. Credentials live in one place
    and deployments pick up psycopg3 without hand-edited ini files.
    config.set_main_option() is avoided because ConfigParser reads "%" as an
    interpolation marker and would mangle passwords containing it.
  * track alembic/pfsa-db01-gb-dev/ and add credential-free alembic.ini files,
    so every deployment directory is runnable from a fresh checkout.
  * lint and format the alembic env.py scripts. The previous blanket exclusion
    of alembic/ was broader than its own rationale, which only covers the
    historical revision scripts under versions/.

Removed: reset_all, reset_target, reset_fluxstd, reset_sky, insert_by_copy,
fetch_by_copy, insert_mappings, rollback. None are called from
ets_target_database, ets_pointing or pfs_obsproc_planning_tools. insert() no
longer takes return_defaults. connect, close, fetch_query, fetch_all and
fetch_by_id are kept unchanged for downstream callers.

Tests: new unit coverage for URL construction and the API surface, plus
integration tests proving the COPY dry-run rollback against a real server,
the absence of connection leaks, and TARGETDB_CONF-driven alembic.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…l password

Follow-ups to the pfs-utils migration, found while auditing the branch for
leaked credentials. No real credential was committed: every password-like
string in tracked files is a placeholder, and all URL logging goes through
render_as_string(), which masks it.

The audit did turn up three problems:

  * draw_diagram() raised KeyError on a config with no password. Making the
    password optional left the SchemaCrawler path indexing it directly, so the
    very configs that change enables would crash. Omitting --password is safe:
    pgjdbc resolves the password from PGPASSFILE, or from ~/.pgpass via the
    JVM's user.home. Verified against a live server -- a password-less config
    plus PGPASSFILE produces a diagram, a wrong password in that file fails
    authentication, and no file at all reports that none was supplied.
    The password is now appended after the debug log rather than before, so it
    is no longer written to the log verbatim.
  * examples/docker/db-data/ is a live 48MB PostgreSQL cluster that was neither
    tracked nor ignored, so `git add -A` would have committed it. It holds no
    role secrets today (the example cluster uses trust auth), but global/ would
    carry a SCRAM verifier the moment one is set. Now ignored, along with the
    alembic run logs and local tool state.
  * a comment claimed DB.__init__ logs self.url; it logs only the database
    name.

The four alembic note files are consolidated into alembic/README.md. The two
deployment READMEs were byte-identical, and local_test/ held only the stock
one-liner. Their 2022 debugging material is kept as lessons rather than a log:
the duplicate-row SQL is generalised and paired with the UniqueViolation it
explains, and memo.md yields a gotcha worth keeping -- SQLAlchemy cannot
reflect expression-based indexes, so autogenerate is blind to every q3c index
in this schema. Stale paths, row counts and Python 3.9 tracebacks are dropped;
git history still has them.

Finally, remove requirements.txt. Nothing referenced it -- not CI, Docker or
the docs -- and it had drifted in both directions: it still listed
psycopg2-binary and tomli, and was missing pfs-utils, psycopg and requests.
Anyone trusting it would have installed the driver this branch removed. The
pdm gen-requirements script that regenerates it goes too; the rest of the PDM
setup is left for a separate change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@monodera

monodera commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@wtgee I've tried to use pfs_utils for database connection, while keeping the compatibility to the already-used APIs. It would be great if you briefly check if the use is intended.

Subaru-PFS/ets_pointing#44 (comment)

@monodera

monodera commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@wanqqq31 Could you please test the integration code as is with the tickets/OBSPROC-140? (meaning, the API compatibility is maintained)

@wtgee wtgee left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess you need to maintain psycopg2 compatibility? All of this looks fine. We could move some of these features into pfs_utils directly: password ability; dry run support; dropping unknown columns, etc. I'm mostly putting that here for my own mental note as it is beyond the scope of this PR.

Note that I only reviewed src/targetdb/targetdb.py and src/targetdb/utils.py but LGTM.

@monodera

monodera commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

I guess you need to maintain psycopg2 compatibility? All of this looks fine. We could move some of these features into pfs_utils directly: password ability; dry run support; dropping unknown columns, etc. I'm mostly putting that here for my own mental note as it is beyond the scope of this PR.

I dropped pyscopg2 support as all dependent packages can be used with psycopg3, and both 2 and 3 versions can co-exist if needed. Also, packages using targetdb can also work with psycopg3, I suppose.

For additional features for pfs_utils, closing the connection at error would be nice.

@wtgee

wtgee commented Aug 6, 2026

Copy link
Copy Markdown
Member

I guess you need to maintain psycopg2 compatibility? All of this looks fine. We could move some of these features into pfs_utils directly: password ability; dry run support; dropping unknown columns, etc. I'm mostly putting that here for my own mental note as it is beyond the scope of this PR.

I dropped pyscopg2 support as all dependent packages can be used with psycopg3, and both 2 and 3 versions can co-exist if needed. Also, packages using targetdb can also work with psycopg3, I suppose.

Got it, I see you are just keeping compatibility with the old config files.

For additional features for pfs_utils, closing the connection at error would be nice.

Good idea, I'll make a ticket. We purposefully left password support out but I can see needing it in the real world.

You have a host set in your config files but it is probably worthwhile to set sane defaults otherwise (maybe I missed it somewhere), like:

https://github.com/Subaru-PFS/pfs_utils/blob/master/python/pfs/utils/database/opdb.py#L13-L16

monodera and others added 2 commits August 5, 2026 18:34
TargetDB.__init__ carried non-None defaults (host="localhost", etc.) and
raised ValueError when dbname/user were omitted, so the class-level
DEFAULT_HOST/DEFAULT_USER/DEFAULT_DBNAME/DEFAULT_PORT that DB.__init__
resolves omitted arguments from were unreachable, and the inherited
set_default_connection() classmethod was a silent no-op. Bring TargetDB in
line with the OpDB/QaDB convention in pfs.utils.database.db: declare the
four DEFAULT_* attributes and default every __init__ parameter to None.

DEFAULT_USER is the read-only `obsproc` account rather than the `pfs` that
OpDB/QaDB use, since every write path in this package already passes an
explicit, privileged user from a config file -- a bare TargetDB() can now
read production but never write to it. Confirmed against the live database
that obsproc has SELECT-only grants and that INSERT is rejected.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@wtgee

wtgee commented Aug 6, 2026

Copy link
Copy Markdown
Member

Thanks for taking the time to make it compatible! I ❤️ code consolidation!

monodera and others added 2 commits August 5, 2026 18:56
…tring dtype

pandas' own default string dtype (the default from pandas 3.0, and
available earlier via future.infer_string) uses its own NA sentinel, so
assigning None into a filter_* column with that dtype silently rewrote it
back to NaN -- defeating the point of the function, since a NaN reaching
the database driver gets coerced to the literal string "NaN" and violates
the filter_name foreign key. Casting the column to object dtype before the
assignment keeps None as a real None regardless of which string dtype
pandas resolved the column to.

Found while chasing a test failure that turned out to be caused by running
against pandas 3.0.5 in an ad hoc virtualenv instead of the pandas 2.3.3
this project's uv.lock pins -- the project's own pyproject.toml has no
upper bound on pandas, so the same failure would have hit real inserts
once pandas 3 was eventually resolved.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants