Open Drug Database for Switzerland. See the live version at http://ch.oddb.org
- SDIF Interactions: Drug interaction checking uses the SDIF (Swiss Drug Interactions Finder) SQLite database (
data/sqlite/interactions.db). Four sources: EPha.ch curated ATC-to-ATC interactions, substance-level matches, ATC class-level keyword matching in Swissmedic FachInfo text, and CYP enzyme-mediated interactions. Each interaction shows its source (EPha.ch or Swissmedic FI) with type badge (Wirkstoff, ATC-Klasse, CYP). Route indicators (topisch, i.v., s.c., etc.) and approved combination therapy hints are displayed next to drug names. FI results display a "Gegenrichtung hat höhere Einstufung" hint when their severity is below the pair maximum across all interaction types. EPha results show the hint only for asymmetric EPha ratings between directions. - Twitter share and mail/notify icons have been removed from drug search result lists.
- The
desitinflavor was retired in August 2026 on request. Unlike just-medical it was still in use — 1206 requests from 459 distinct addresses in that month — so visitors now get the gcc default: the urls keep working, the branding and the desitin-specific result ordering do not.LookandfeelDesitin, its css theme and the desitin branches inState::GlobalandUtil::ResultSortremain in the tree, so restoring theWRAPPERSentry revives it. - The
just-medicalflavor was retired in August 2026: its hostname no longer resolves and the path flavor served no requests. Removing itsLookandfeelFactory::WRAPPERSentry is what retires it — sessions asking for it fall back togcc. The med-drugs xls export tomed-drugs@just-medical.comis a separate thing and is unaffected. - Some email-Addresses are still hardcoded. That needs to be fixed and placed into etc/oddb.yml
- If you install oddb.org via gem please also see these instructions.
git clone https://github.com/rbenv/ruby-build.git "$(rbenv root)"/plugins/ruby-buildrbenv install 3.4.5- Linux:
sudo apt-get install apache2 daemontools daemontools-run pkg-config libmagickwand-dev libpq-dev libmagickcore-dev graphicsmagick uuid-dev - macOS:
brew install libpq graphicsmagick ossp-uuid(usebundle config build.pg --with-pg-config=$(brew --prefix libpq)/bin/pg_configfor the pg gem) bzcat 22:00-postgresql_database-ch_oddb-backup.bz2 | su -c psql -l postgres -p 5433 ch_odd- see Guide.txt
Reparse compositions of 5 digit Swissmedic Numbers (issue #139)
sudo -u apache bundle exec ruby jobs/import_swissmedic_only update_compositions 67685 60134
sudo -u apache bundle exec ruby jobs/import_swissmedic_only update_compositions
sudo -u apache bundle exec ruby jobs/import_swissmedic_only check
bundle exec ruby jobs/update_textinfo_swissmedicinfo --skip --target=both 62822 --reparse
bundle exec ruby jobs/verify_chapter_reachability <ids.txt> walks the live objects and checks a list of supposedly unreachable ids against them — run this before deleting anything.
bundle exec ruby jobs/delete_unreachable_objects counts what is unreachable per class; --upto=<n>, --extent=<class> or --all plus --apply deletes it, logging every id. ChangeLogItem, Array and Hash are never touched.
bundle exec ruby jobs/repair_fachinfo_references reports registrations whose @fachinfo points at something that cannot be a Fachinformation; --apply clears the reference. It asks the database rather than the objects — a registration legitimately references Hash, Company, Indication, Fachinfo, Patent, Registration and MiniFi and nothing else — because the broken value is an ODBA::Stub declaring ODDB::Fachinfo while resolving to something else, so is_a? says yes and only respond_to? tells the truth by fetching the object. Runs monthly from cron and exits non-zero on a find.
bundle exec ruby jobs/repair_dead_bag_agents walks all registrations and reports ActiveAgents that no longer resolve, plus compositions whose @active_agents is nil; --apply removes them and writes an undo log. It leaves @inactive_agents alone — nil is the normal state on 6885 older compositions and no damage.
bundle exec ruby jobs/repair_pointer_index reports rows of the oddb_persistence_pointer index that do not lead to the object the pointer names; --apply deletes them and writes an undo log. Two sorts: rows whose target is an Array or Hash — those are the dangerous ones, find_by_pointer hands them out — and rows whose target no longer exists, which are inert leftovers of earlier repairs. Dry by default, exits non-zero on a find.
bundle exec ruby jobs/deactivate_vanished_registrations reports registrations that are still active but have dropped out of the Packungen lists and are no longer in Präparateliste-latest.xlsx; --apply deactivates them with the date of the first list that missed them. The Präparateliste is required — without it an export authorisation, which has no Swiss package and so never appears in the Packungen list, cannot be told from one that was struck off.
bundle exec ruby jobs/fix_deregistration_dates compares inactive_date against the Swissmedic Packungen lists in data/xls and reports what is wrong; --apply writes it and logs every change for undo.
bundle exec ruby jobs/repair_patinfo_sequences walks every Patinfo and reports those whose @sequences is not a list; --apply restores it from the sequences that point back and writes an undo log. It walks all ~14 500 objects rather than filtering on object_connection, which costs about a minute and is the only way to get a number that holds — an edge does not say which ivar it came from, and a nil or dangling @sequences has no usable edge at all. Dry by default, exits non-zero on a find.
bundle exec ruby jobs/build_fachinfo_year_feeds --apply writes fachinfo-<year>.rss from the documents' change logs (about fifteen minutes for 6287 Fachinfos; runs nightly from jobs/update_fachinfo_rss_feeds).
bundle exec ruby jobs/build_patinfo_year_feeds --apply does the same for the patient information — patinfo-<year>.rss plus a small patinfo.rss with the newest fifty changes (19 minutes for 6427 Patinfos; runs nightly from jobs/update_patinfo_rss_feeds).
bundle exec ruby jobs/build_price_archives --apply rebuilds the monthly price archives from the packages' price history.
bundle exec ruby jobs/split_rss_archives <channel>.rss --apply cuts an existing feed into monthly files.
Each of the three is dry by default and writes only with --apply.
Note: The fiparse daemon (DRb on port 10002) runs as a separate process managed by daemontools (/etc/service/fiparse). After making code changes to ext/fiparse/src/, restart the daemon with sudo svc -h /etc/service/fiparse for changes to take effect.
Tables from swissmedicinfo are parsed by detect_table? in ext/fiparse/src/textinfo_html_parser.rb. Tables with percentage-width styles are rendered as preformatted text; others are rendered as HTML tables with proper colspan/rowspan support. The view (src/view/chapter.rb) only emits colspan/rowspan attributes when > 1 to avoid invalid colspan="0" in the output.
The app uses Swiyu wallet-based authentication (OID4VP). After exceeding the 5-search query limit, users are prompted to log in. The login flow passes a return_url parameter so users are redirected back to their last search result after authentication. The verifier runs on a separate host (swiyu.ywesee.com, repo swiyu4health) and authorises ch.oddb.org by IP whitelist. As of August 2026 SwiyuClient sends a DCQL query rather than a presentation_definition (swiyu-verifier 4.x rejects the latter with dcqlQuery: must not be null) and uses response_mode: "direct_post.jwt" — swiyu wallet 1.17 enforces payload encryption, and a plain direct_post is refused with invalid_request. The requested credential format is held in the CREDENTIAL_FORMAT constant (vc+sd-jwt, as issued; the newer canonical dc+sd-jwt is accepted too). When a login fails, the wallet only ever shows invalid_request — the real cause has to be read from the verifier host's /var/log/apache2/swiyu-access.log, where a GET …/request-object/<id> without a following POST …/response-data means the wallet rejected the request object itself.
The BSV SL (Spezialitätenliste) data is imported from FHIR NDJSON exports. As of June 2026, jobs/import_bsv uses the FHIR NDJSON source by default (mirroring oddb2xml); pass --no-fhir to fall back to the legacy XMLPublications.zip import. The dedicated jobs/import_bsv_fhir job remains as an explicit alias. The FHIR data follows the ch-epl Implementation Guide. As of the Feb 2026 IG update, productPrice and costShare are nested inside the reimbursementSL extension on RegulatedAuthorization resources. As of April 2026, the import downloads three per-language NDJSON files directly (foph-sl-export-latest-{de,fr,it}.ndjson) so French and Italian product names and limitation texts are populated alongside the German source. As of June 2026, change detection no longer re-downloads the ~93 MB files every run: a cheap HTTP HEAD reads each -latest- file's Last-Modified date, derives the immutable dated link (foph-sl-export-<YYYYMMDD>-<lang>.ndjson), and saves it in a <file>.source sidecar — the download and reparse are skipped entirely while the date is unchanged. As of July 2026, the SL-introduction / price-change flags that drive the med-drugs xls export (OuwerkerkPlugin, sent to just-medical) are computed correctly again: fix_flags_with_rss_logic_for now reads the previous price via pack.price_public(1) (the new price has already been stored at index 0 by the time it runs), mirroring the RSS feed logic. Previously it read the just-stored current price, so new Kassenzulässigkeiten (:sl_entry), price cuts and price rises were silently dropped from the export while sl_introduction.rss still listed them. As of August 2026 limitation texts are imported again: BAG no longer inlines them in a limitationText sub-extension but publishes them as separate ClinicalUseDefinition resources referenced via limitationIndication, so the importer now follows that reference. Without it every limitation was skipped and only 16 legacy texts remained in the database while the export carried some 6300. The referenced resource id is the LimitationCode (e.g. ABEVMY.07), which BAG confirms is limitation-specific and stable across publications; the FR/IT exports use the same ids with translated text. The BAG "IndC" code (indicationCode, e.g. 22064.07) is rendered as the subheading of the limitation text.
August 2026 — BAG moved the export. https://epl.bag.admin.ch/static/fhir/foph-sl-export-* became https://epl.bag.admin.ch/static/sl/publication/fhir/foph-sl-publication-*. The old -latest- alias answers 404 while the old dated snapshots stay in place, so an importer that only HEADs -latest- reports success every morning and downloads nothing — import_bsv had been doing exactly that since 14.08.2026, exit code 0 and all. Constants FHIR_BASE_URL and FHIR_FILE_PREFIX now point at the new location, where a -latest- alias exists again.
Because the alias has now vanished once, newest_dated_url no longer depends on it: when the HEAD fails it walks back day by day from today (DATED_LOOKBACK_DAYS, 45) and takes the first dated file that answers, stopping at the date in the .source marker — nothing newer than what we hold means nothing to do. On a normal day this costs one HEAD, because -latest- is tried first.
The monthly med-drugs xls sent to just-medical (OuwerkerkPlugin) runs as a follower of jobs/import_bsv and combines registration-level change flags from the :swissmedic log group with package-level flags from :bsv_sl. As of August 2026 these change flags are merged into the month's existing log instead of overwriting it (Updater#merge_previous_change_flags, shared with the BSV path). Each import run only reports what changed since the previous download, so without the merge a second run in the same month wiped out what the first had recorded. That is exactly what happened in July 2026: Swissmedic published a corrupt "Zugelassene Packungen" file on 2026-07-04 (wrong Packungsgrösse on ~3200 packages) and republished it corrected on 2026-07-08; the later run compared the corrected file against the corrupt one, produced a :size-only mass diff and overwrote the 27 new registrations recorded by the earlier run. Since :size has no equivalent in OuwerkerkPlugin::NUMERIC_FLAGS, every Swissmedic row was then filtered out and the August export contained no new registrations. SwissmedicPlugin#warn_if_degenerate_diff additionally logs a SwissmedicDiff SUSPECT warning when a diff is dominated by a single flag, which is the signature of a corrupt file on one side of the comparison.
jobs/check_indexing verifies whether the pages behind a Search Console "Serverfehler (5xx)" report actually still fail for Google. Note what the API can do: there is no endpoint that lists the URLs behind the Page Indexing report — the old urlcrawlerrorssamples API was shut down in 2019 and never replaced, leaving only Search Analytics, Sitemaps, Sites and URL Inspection. So the candidate URLs come from our own app log (log/YYYY/MM/DD/oddb_log, lines with status 500) and each is looked up individually through the URL Inspection API, which reports pageFetchState (SERVER_ERROR, SOFT_404, SUCCESSFUL, …), verdict, coverageState and lastCrawlTime. Quota is 2000 inspections per property per day and 600 per minute; --limit caps the former (default 200) and the client throttles for the latter.
Properties can be created entirely through the API, contrary to Google's own documentation, which states that verification requires user authentication and that service accounts cannot do it — the only blocker was siteverification.googleapis.com not being enabled in the project. POST /siteVerification/v1/token returns the token, POST /siteVerification/v1/webResource verifies it, and webmasters/v3/sites registers the property (that call needs the writable webmasters scope). generika.cc was verified with the FILE method and needed no DNS access at all; oddb.org had to go the DNS_TXT route, because its :80 redirect answers 302 for every path except /.well-known/ so Google can never fetch a verification file there — and a DNS property covers the subdomains anyway.
Authentication is a Google service account: the client signs an RS256 JWT with the account's private key and trades it for an access token, implemented on stdlib rather than pulling in googleauth/signet. Set gsc_service_account_json in etc/oddb.yml to the key file, and add that account's client_email as a Full user of each Search Console property (Restricted is not enough). Properties are configured per domain in gsc_site_urls; a sc-domain: property covers its subdomains, so one oddb.org entry serves ch.oddb.org, i.ch.oddb.org, oekk.oddb.org and desitin.oddb.org. Hosts without a property are skipped rather than spending quota — which also filters out the mangled Host headers some crawlers send (chahmer.ch for nachahmer.ch).
bundle exec ruby jobs/check_indexing --dry-run # list the urls, call nothing
bundle exec ruby jobs/check_indexing --days=30 --limit=500
bundle exec ruby jobs/check_indexing --url=https://ch.oddb.org/de/gcc/... --mailAbout 25000 requests returned 500 in August 2026, on exactly the pages Google crawls. Four fixes cover roughly 24500 of them. The largest by far was every /diff/ change-log page: Diffy tags the external diff output ASCII-8BIT when it is not valid UTF-8, the html formatter then re-diffs character by character and splits multi-byte sequences into single bytes, and writing those to a Tempfile transcodes and raises — but only because config.ru sets Encoding.default_internal, which is why it never reproduced in tests. View::Drugs.utf8_diff re-tags the text and scrubs only the genuinely invalid bytes. The rest were broken ODBA references on patinfo and fachinfo pages, where ODBA::Stub#is_a? answers from the declared class without resolving the stub, so a guard written as is_a? Text::Chapter passed and the next call raised; the guards use respond_to?, which does resolve. A related trap: unless document&.empty? lets a nil document through, because PatinfoDocument#empty? returns nil and so does nil&.empty?. The same family turned up in view/admin/sequence.rb, where the guard needs care: the obvious respond_to?(:de) would reject every real substance, since SimpleLanguage defines no de/fr/it methods and instead routes any two-letter symbol through method_missing with a matching respond_to? override. Be aware these failures are intermittent — the same URL can answer 500 and then 200 minutes later without a restart, because whether an ODBA stub resolves depends on cache and connection state, so a single URL is never proof that a fix worked. Worth knowing that the single biggest cause was not code. Three of the four rack backends were dead for days while svstat still reported them up — crawler for 18.6, generika for 10.3, google_crawler for 9.7 — and Apache answered every request to those ports with a 503 for the duration. svstat reports on supervise, not on the child, so counting requests per day in log/YYYY/MM/DD/<service>_log is what makes an outage visible. oddb_cron healthcheck now asks the four ports every minute and restarts a backend that has been silent for five, which is well past the normal restart cycle these processes go through whenever they pass their memory limit.
A second measurement trap: Anubis answers curl with a 500 while serving browsers normally, so a URL tested from the shell can look broken when it is not. Send a Googlebot user-agent, which bypasses Anubis, or probe http://localhost:8012 directly.
A second round on 28.08.2026 brought the rate from 1.3 % (15.4 % on 15.08., while the backends were dead) down to 0.13–0.15 %, on ten times the traffic — requests went from ~22 000 to ~250 000–340 000 a day once the dead backends came back. Two causes were left. A Latin-1 umlaut in a chapter heading raised Encoding::UndefinedConversionError on every minifi page of the affected drug: #formats has always converted ASCII-8BIT as ISO-8859-1 for paragraph text, the heading did not, and what stood there could not have worked — the repair ran after the heading was built and head.encode("utf-8") threw its result away. And a string where a document belongs took the Fachinfo page down through four layers in a row, each guard only uncovering the next; Drugs.change_log_size moved from patinfo.rb to change_logs.rb, which fachinfo.rb already loads. Ask respond_to? with a Symbol, not a String: SimpleLanguage#respond_to? takes either, but FlexMock answers only symbols, so a String guard passes in production and fails three existing tests. Three registrations were a data defect rather than a code one — they held a PatinfoDocument under @fachinfo, every guard just moved the failure one line down, and Google had crawled one of them and filed the server error. jobs/repair_fachinfo_references cleared them; they answer 404 now, like every other registration without a Fachinfo. Note that a large share of what remains in the log is not a real page at all: /dt/gcc/… uses a language code that does not exist, and month/Arial, api/v1/validate/code and test/lm.php are bot probes. Count distinct valid urls before concluding there is a bug.
A third round on 29.08.2026 came from reading our own log rather than the Search Console — of the 28 urls that had answered 500 in three days, Google's URL Inspection API called every single one "unknown to Google". All 28 answer 200 now, on a cold cache. The lesson that cost the most: a dangling reference is not a reference of the wrong class, and respond_to? cannot tell you about the first. It resolves the stub, so when the target is gone it raises ODBA::OdbaError instead of answering false — which is exactly what the August guards in view/admin/sequence.rb walked into. View::Admin.resolvable_agents and .any_agent? rescue it now; without the rescue one bad element takes the whole sequence page down. Note also the asymmetry between two encoding repairs that look identical: ASCII-8BIT means UTF-8 bytes in view/tooltip.rb and Latin-1 in view/chapter.rb, so TooltipHelper.to_utf8 tries the first and falls back to the second — encode("UTF-8") alone cannot move a single byte over 0x7F out of an ASCII-8BIT string, it raises. And these pages answered 500 on the first request and 200 on the second, which is why a crawler always saw the error and a hand check never did: probe a cold url, or restart first.
A fourth round on 31.08.2026 started from Google rather than from us, and had to: the URL Inspection API answered clean for all 67 urls the log still offered — 0 SERVER_ERROR, 2 indexed, 65 unknown — with the rate over all four backends down to 0.014 % (31 of 221 817 on 30.08.). Probing a handful of key pages by hand is what turned up two more faults, both of the by-now familiar family where an ODBA reference holds an object of the wrong class.
The first was in Persistence::Pointer#resolve, and it is the most instructive of the whole series. resolve asks the index first and short-circuits on any truthy value — an empty Array included, so a bad row was handed straight to the view, which died on @model.sequence. Two things made it hard to see. find_by_pointer ends in .first over a query with no ORDER BY, so with two rows for one pointer Postgres decides which wins, and the same url answered 500 three times and 200 minutes later with no restart. And the pointer walk that looks like a fallback is not one: resolve is called as pointer.resolve(@session.app), and ODDB::App answers respond_to?(:registration) with false because it dispatches through method_missing — so the walk raises InvalidPathError on the first step, for every pointer. A first fix that guarded the index hit and fell through therefore only traded one 500 for another, which the restart exposed immediately. The working fix is to take the right row: search_by_exact_pointer returns all of them, and resolve takes the first that is a Persistence. Over all 603 288 rows, 30 led to an Array or Hash and 276 to an object that no longer exists; jobs/repair_pointer_index removed all 306. Google had carried one of these pages as a server error since 03.05.2026.
The second was Patinfo#_sequence_delegate calling @sequences.first where 24 of 14 554 Patinfos held a PatinfoDocument. Three things are worth carrying forward. instance_variable_get on an ODBA::Stub reads the stub's ivars and reports @sequences as nil — resolve with odba_instance before asking, or you get a clean "nothing broken" that is wrong. A prefilter over object_connection is not a substitute for walking the objects: it gave 38 edges over 31 objects, 7 of which had a perfectly intact list, because an edge does not say which ivar it came from. And when restoring a list, store the list first and by itself, then the holder — it is its own ODBA object and lives in the holder's dump only as a stub, so the other order turns a broken reference into a dangling one. SequenceObserver#add_sequence has always done it right and is what the repair reuses. The guard in the model tries .first and rescues instead of asking is_a?(Array), because a dangling stub declaring Array answers that question with yes and raises on the next line.
Replaying every distinct url that returned 500 on 30. and 31.08. afterwards: 42 of 46 answer 200. One of the four is the intermittency itself — 500 cold, 200 on the second request. Two more were a third instance of the same family, and the way it was found is the point: Session#request_path calls change_log.select for a patinfo diff url, and 39 of 29 969 documents hold a PatinfoDocument in @change_log. FachinfoDocument#change_log has carried a "safety check, reset it if corrupted" guard for a long time; PatinfoDocument#change_log was the bare @change_log ||= [] — which is exactly why the failure appears on patinfo diffs and never on fachinfo diffs. When one of two sibling classes is guarded, read the other. Both now probe rather than ask, because a dangling stub declaring Array answers is_a? from the declaration and only raises on the access, and both log through LogFile rather than puts, which out of a web process lands unfiltered in the service log. The reset persists with odba_store and deliberately not odba_isolated_store: the fresh list is its own ODBA object, and only the non-isolated call takes it along — the isolated one would leave a dangling stub behind. No data repair was needed; each of the 39 heals the first time its page is opened. Note that nil is not damage here — 6911 documents have no change log because the FI one only starts in November 2015 and the PI one in August 2017. The last one was a nil @components in view/drugs/patinfo.rb: PiChapterChooser#init mixed the accessor components (which is @components ||= COMPONENTS.dup) with the raw ivar, and the block that would otherwise call the accessor first is skipped for a [:create] pointer — so the ivar was still nil at the end of the method. All 46 urls that returned 500 on 30. and 31.08. answer 200 now.
One thing worth recording about that last stretch, because the conclusion was wrong before it was measured: 17 of the 24 repaired Patinfos were valid?=false and looked like they needed a text reparse. They did not. None of the 17 is reachable from any live registration, while all 7 valid ones are, and every live sequence of the registrations involved already has a good Patinfo with a real document. The drug names that made them look important came from sequences found in object_connection, and 11 of those are dead objects that still carry a registration reference without appearing in reg.sequences. Two of those registrations are the ones whose pointer strings carried 70 and 36 index rows — a drug re-imported that often leaves a generation of orphaned sequence, package and patinfo objects behind each time. Ask whether anything still reaches an object before repairing it.
/de/gcc/patinfo/reg/31706/seq/02 — Inderal 40 mg — showed the leaflet of Fluoxetin Sandoz eco, and in French that of HerbAllerg. Not a truncated text: a foreign document sitting under the odba_id the stub points at, the duplicate-odba_id collision described below, this time on Patinfo documents. Measured across every active registration in de and fr: 420 registrations, 264 documents carry another drug's Swissmedic number in their text, and 67 of them all point at the same foreign document — one overwritten object, 67 stubs.
The Swissmedic number in the text is the measure, and nothing else is. Two earlier measurements were artefacts and both looked like disaster: PatinfoDocument has no chapters method — its chapters are individual instance variables, @date among them — so a doc.chapters rescue [] counted every document as empty (4802 registrations, 70 %); and "fewer than 4 of 16 ivars filled" flagged 916 more whose pages render 87–106 KB of text like any healthy leaflet, mostly French documents with a different chapter layout. A number above 30 % is a reason to look at the pages before believing it.
The reparse is the repair: jobs/update_textinfo_swissmedicinfo --skip --target=both <iksnrs> --reparse writes a fresh document and the old one is dropped by superseded_document.rb. 31706 alone took 74 seconds and the page showed Inderal afterwards; all 420 in one process took 30.5 minutes and 412 of 420 now carry their own text, read back cold. About four seconds per registration.
Eight remain, in three classes. Four — 54238, 55476, 63235, 63287 — have no patient leaflet at swissmedicinfo at all (absent from the AipsDownload, or SmPC only), so there is nothing to reparse from and the foreign document stays; the right repair would be to delete it, a leaflet for the wrong drug being worse than none, and that decision is open. Two — 14747 Minalgin and 53222 Palladon — do have a leaflet at swissmedicinfo, the log says Updating 14747/01/036, and yet afterwards pack.patinfo == seq.patinfo with empty descriptions and valid? false: the foreign text is gone (the page answers 404 instead of lying) but nothing was stored. And 42127 Anxiolit has a leaflet, logs Checking 42127 pi de unchanged false, and never stores; with --target=pi nothing happens at all. Those three are a fault in store_patinfo_for_all_packages, not in the data, and are still open.
The measurement exists only as a scratch script. It found 420 damaged registrations nobody had noticed; as a monthly jobs/check_patinfo_texts beside repair_fachinfo_references it would stand guard over exactly this class of collision.
Nothing writes a document into @sequences. The Array was overwritten in the database by a document that a second process had been handed the same odba_id. The reference in the ivar is fine and points at the right number; a different object simply sits under that number now. That is why searching the source for the offending assignment turns up nothing — there is none, and the same root cause explains the mis-referenced pointer-index rows and the dangling-Array family from the earlier rounds.
ODBA::Storage#next_id was @next_id += 1 under a mutex, with @next_id seeded once per process from SELECT odba_id FROM object ORDER BY odba_id DESC LIMIT 1. The mutex protects one process; ch.oddb.org runs four rack backends, ext/readonly and every job with a counter each. Measured with two processes side by side, both answered [61935067, 61935068, 61935069]. ODBA::Cache#next_id did have a guard for exactly this — peers raise OdbaDuplicateIdError and the method retries — but peer.reserve_next_id id rescue DRb::DRbError is a rescue without a class: it catches every StandardError, that one included, so the retry could never run. odba 1.2.0 and 1.2.1 only reformatted it into a block rescue that is still unfiltered, so upgrading does not help. @file_lock, which would have selected odba's cross-process file counter instead, is false and is set nowhere.
The fix is a Postgres sequence, odba_id_seq, created at MAX(odba_id) + 100000 — the gap covers ids that running processes still hold in memory, and skipped numbers cost nothing since the odba_id carries no meaning — plus narrowing that rescue to DRb::DRbError. Both live in src/util/odba_id_patch.rb, in the manner of odba_connection_patch.rb.
log/job.pid does not cover this, which is the obvious thing to assume. Util::Job.updater? matches only ^update|^import|^rebuild|^repair, so build_*, export_* and check_indexing never take the lock; the rack backends never take it either and still allocate ids (the change_log guard, SimpleLanguage#description storing a fresh Descriptions on a read, feedbacks, create_patinfo); and ext/readonly does not go through Util::Job at all. The lock stops two importers colliding, not an importer colliding with a page being served — and the damage signature, a document created 1–16 ids after its Patinfo, is exactly the latter.
Note ext/readonly/bin/readonlyd: it requires etc/db_connection directly and never loads oddbapp, so it needs the patch required explicitly. That is also the argument for releasing the fix in odba itself rather than living with a monkey-patch — the next process added that way brings the bug back silently, and yus depends on odba (>= 1.1.6) with the same weakness in its own database.
Both fixes are released as odba 1.2.2 (github.com/zdavatz/odba, tag v1.2.2): Storage#next_id takes the id from the odba_id_seq sequence that #setup creates, and Cache#next_id catches only DRb::DRbError so a peer conflict reaches the retry. The sequence's start value is computed from MAX(odba_id) plus a gap and has to be — a plain CREATE SEQUENCE starts at 1 and would re-issue ids that already exist.
The Gemfile moved to odba 1.2.2 on 31.08.2026 and to odba 1.2.3 on 01.09.2026 (see below). Note that bundle install alone would never have done it — the version is pinned exactly, so the Gemfile line had to change.
Two things had to happen first. 1.2.0 deleted lib/odba/18_19_loading_compatibility.rb while oddbapp.rb required it, which would have been an immediate LoadError in all four backends; that file now lives unchanged in the tree as src/util/odba_18_19_compat.rb, because the two Descriptions objects that would not migrate still need it. And src/util/odba_id_patch.rb disables itself with return if ODBA::Storage.method_defined?(:id_sequence?) — it stays on disk so that dropping back to 1.1.9 does not quietly reintroduce the id collisions, and so readonlyd keeps its require. Its tests were rewritten to assert the guarantee (is there a shared id source at all; does a peer conflict yield a new id) rather than our implementation, which after the upgrade would have been testing a no-op and staying green.
1.1.9 → 1.2.2 is 3558 changed lines over 15 files, nearly all the standardrb reformat 1.2.0 did — the same shape as the sbsm 1.6.3 trap where a reformat silently emptied a sanitising branch. A full test run and a boot against the real database are the minimum before restarting.
Do not delete 18_19_loading_compatibility on the grounds that we run Ruby 3.4. The name misleads: it is not a shim for running under 1.8/1.9 but for reading data written by it, and it defines Date._load and Encoding::Character::UTF8._load. Measured in object: 4217 dumps still use the old lowercase u:\tDate userdef form — Ruby 3.4 writes U, i.e. marshal_load, which the shim does not touch — and 129 carry Encoding::Character::UTF8. Loading one of those with the shim yields an ODDB::Text::Chapter; without it, ArgumentError: undefined class/module Encoding::Character::UTF8. jobs/rewrite_legacy_dumps does that — dry by default, --apply, --limit and --extent for batching, --verify to load the remainder with the shim's module taken away, and an undo file holding every old dump, which matters more here than in the other repair jobs because a Marshal format cannot be computed back. On 31.08.2026 it converted 4341 of 4346 (after a 3.8 GB pg_dump, deleted on 01.09.2026 once the migration was verified; the undo files — 16 of them, 140 MB, 4479 lines — stay, being the finer net, since they restore one object rather than the whole database).
Two things are worth knowing before touching this. The old string is not an instance of Encoding::Character::UTF8: the dump uses Marshal type e, a String extended with the module Encoding::Character::UTF8::Methods — which is why the shim defines that otherwise empty module. Its class is String, so searching for the class finds nothing and re-storing writes the extension straight back, byte for byte. Only walking the object graph and replacing such strings with dup (which does not carry the singleton class over) removes it; that was found by decoding the bytes around the marker, not by reading code. And when the 356 Registration objects were written, ODBA logged undefined method 'values' for nil when updating index 'sequence_vaccine' — a pre-existing fault in that index definition. Comparing sequence_vaccine from the pre-migration dump against the live table showed 263 rows on both sides with identical content, so nothing was lost, but that is the kind of thing to check rather than assume.
Five objects remain and the shim therefore stays — it now lives in the tree as src/util/odba_18_19_compat.rb rather than in the gem. Three are IncompleteRegistration rows whose class no longer exists; they raise undefined class/module with and without the shim, so they block nothing.
The other two are not orphans, and an earlier note here saying otherwise was a misreading — a mistyped odba_id in my own check, whose "false" I took as evidence. They are the @descriptions of two ATC guideline documents: 27827703 hangs off Text::Document 1121393 → AtcClass A10BB (ddd_guidelines), and 27829500 off 1122154 → B03A (guidelines). The data is intact: read through the model they return the correct WHO text, and the ATC pages answer 200. Both parent documents are already clean at 752 and 742 bytes; only these two children still carry the String extension, and loading one raw gives ODDB::SimpleLanguage::Descriptions with the shim and ArgumentError: undefined class/module Encoding::Character::UTF8 without it. So they are exactly why the shim stays — a 77-line file, not a data defect. What is still open is the narrow question of why odba_isolated_store leaves those two rows byte-identical; its only consequence is keeping that file.
--verify is not the release gate it looks like, and this was written down wrongly at first: it reports "ok" for all five leftovers, the three dead-class rows included, because ODBA catches the load failure inside fetch_or_restore and hands something back rather than letting it out. Anyone wanting to drop the shim for real has to load those objects raw — Marshal.load on object.content — instead of going through ODBA.cache.fetch.
On 31.08.2026 port 8212 took 98 913 requests, of which 54 021 came from meta-externalagent — Meta's AI training crawler, 55 % and more than every other bot together (Applebot 2250, Baiduspider 631, Sogou 313). They land on the expensive pages: /fr/gcc/patinfo 9588, /de/gcc/fachinfo 5236, /en/mobile/show 4853, /de/generika/show 4323, /fr/gcc/price_history 3482. A Fach- or Patinfo render pulls a document and its twenty chapters into memory, so the process walks up to MEMORY_LIMIT_CRAWLER and exits — 108 restarts that day against 7 to 12 on each of the other three backends, countable as "silent on port" lines in log/cron/healthcheck-*.log.
Nothing was broken by it: Apache routes bots to their own backend, visitors on 8012 never saw it, and HEALTH_GRACE=5 keeps the healthcheck from firing on that normal exit-and-restart cycle. It is simply a lot of machine for a crawler collecting text to train on, so meta-externalagent was added to doc/robots.txt alongside GPTBot, Amazonbot, SemrushBot and the others already there. meta-webindexer, their search-index agent, is left alone — about 200 requests. robots.txt is served straight off disk, so no restart is needed; Meta re-reads it roughly daily, so the effect shows in the next day's restart count.
/etc/crontab and every script it calls are versioned as etc/crontab and bin/oddb_cron. Install both with sudo bin/install_crontab --apply, which backs up what it replaces; installing the crontab alone breaks every job, because it calls /usr/local/sbin/oddb_cron. One line covers all ten ch.oddb.org jobs — * * * * * zdavatz /usr/local/sbin/oddb_cron schedule runs every minute and the times live in a table inside the script (oddb_cron schedule-list). The certbot and backup lines stay separate because cron takes one user per line and those run as root; the install step makes the script root-owned, which also closes a pre-existing hole where cron ran a zdavatz-owned backup script as root. Job output goes to log/cron/<job>-<YYYY-MM>.log instead of the >/dev/null 2>&1 the old lines carried — a failing import used to look exactly like a successful one. The same schedule line also runs oddb_cron healthcheck, which probes the four rack ports and restarts a backend that has been silent for five consecutive minutes; the grace period matters, because these processes restart themselves whenever they pass MEMORY_LIMIT_CRAWLER and need about forty seconds to answer again, so a check firing on that would be worse than none. Its escalation is covered by sh test/test_bin/healthcheck.sh, which stubs curl, svc and svstat.
Since 28.08.2026 the nightly Postgres dump is versioned too, as scripts/pg_backup.sh, and install_crontab puts it in place — but only once etc/oddb.yml holds db_password, because the versioned copy carries no password and installing it without one would turn the backup into a silent failure. That password used to sit in two world-readable files, etc/db_connection.rb at 644 and /usr/local/sbin/pg_backup.sh at 755; it lives in etc/oddb.yml now, mode 600, where every other secret already is. The script reads it rather than sourcing the file: it runs as root while oddb.yml belongs to the web user, so a . on that file would be a path from a compromised application to root — sed lifts one value and executes nothing. Watch out for the trap that cost a leak here: install_crontab prints the diff of what it replaces, so before it was filtered a plain dry run put -PGPASSWORD='...' on the terminal. Covered by sh test/test_bin/pg_backup.sh and sh test/test_bin/install_crontab.sh.
The script asks psql which databases exist and recognises the footer of that table by its wording, (4 rows). Under de_CH.UTF-8 psql writes (4 Zeilen), the grep misses it, and awk hands back (4 as a database name — which is why a 14 byte (4-backup.bz2 sat beside the real dumps every night. LC_ALL=C on that one query pins it (29.08.2026). The test runs the script's own db_connectivity against a psql stub that answers German unless LC_ALL is C, so taking the pin out makes it fail.
The schedule also carries a monthly repair_fachinfo_references on the 5th at 06:30. It changes nothing without --apply — it only reports registrations whose Fachinfo reference points at something impossible, and exits non-zero so cron mails. The check on the writing side is in Registration#fachinfo=; check_accessor_list had declared it for years, but define_check_class_methods runs at line 60 and the hand-written setter twenty lines further down overrode the generated method, so the declared check was dead.
Apache reads /etc/apache2/sites-available/oddb-ssl.conf, not the repo copy in etc/20_oddb.org.rack.conf — the two had drifted, so both need changing. A catch-all *:80 vhost must stay first: Apache serves the first *:80 vhost for any unmatched Host header, and that used to be sl_errors.oddb.org, so http://oddb.org/ and bot probes with mangled hosts got a directory listing of doc/sl_errors. Note that Redirect / appends the remaining path, so a target without a trailing slash concatenates — http://generika.cc/de/generika/ redirected to https://generika.ccde/generika/; use RedirectMatch where the path should be dropped and a trailing slash where it should be kept. Putting a host in the wrong category is silent rather than broken: four vanity domains pointing at landing pages were given the trailing-slash form, so /de/generika/ arrived at /de/anthroposophy/home/de/generika/, which answers 200 because SBSM ignores the tail. A redirect-only host should also be permanent — www.generika.cc answered 302 for years, which is why a legacy Search Console property for it kept crawling and reporting 5xx. The certificate covers only the names it lists, so a vhost cannot fix a handshake that never completes; expand it with certbot certonly --standalone --expand. Finally, Anubis (bot protection, :443 → localhost:9000 → 8012) answers curl with HTTP 500 while serving browsers normally, so a 500 from https://ch.oddb.org/ says nothing about the application.
Refdata migrated their platform on 2026-04-01. The Partner SOAP service (used for company and doctor imports) requires an API key. Set refdata_api_key in etc/oddb.yml (or the REFDATA_API_KEY env var). Register at developer.refdata.ch to obtain a key.
jobs/update_drugshortage imports current Swiss drug shortages from drugshortage.ch. In May 2026 the upstream site migrated from an ASP.NET HTML page to a WordPress + JSON API; the plugin fetches https://www.drugshortage.ch/api/api_engpaesse.php (the official "show all current shortages" endpoint, ~705 active records — endpoint moved under /api/ in June 2026) and parses gtin, mutation, status, lieferdatum, id per record. status values gained a leading category-number prefix ("1 aktuell keine Lieferungen"), which the plugin strips to keep shortage_state stable. shortage_link is built from the stable upstream id as https://www.drugshortage.ch/index.php/detail-lieferengpass/?ID=<id>. Cached fixture lives at data/json/drugshortage-latest.json. Later in June 2026 the API also began requiring HMAC-signed requests (otherwise HTTP 403 "Zugriff verweigert – fehlende Header"): each call sends X-Timestamp, X-Nonce, an X-Signature (HMAC-SHA256(secret, "<ts>|<nonce>|api_engpaesse") as hex) and X-Requested-With, generated per request by ShortagePlugin#shortage_source_headers.
If the app crashes with PQsocket() can't get socket descriptor, the ODBA connection pool holds dead database connections. A monkey-patch in src/util/odba_connection_patch.rb automatically detects stale connections and reconnects with up to 3 retries. This handles PostgreSQL restarts, idle timeouts, and other connection drops transparently.
If searches fail with NoMethodError: undefined method 'fetch_ids', an ODBA index is corrupted. The app will show an error page with the index name. Rebuild it with:
bundle exec ruby jobs/rebuild_indices <index_name>
For example: bundle exec ruby jobs/rebuild_indices sequence_index
A change_log lives on the FachinfoDocument / PatinfoDocument, not on Fachinfo / Patinfo. Replace a document without carrying the history over and nothing points at it any more. In August 2026 31447 of 125403 change log items — a quarter of the entire diff history — were unreachable: /show/fachinfo/51886/diff listed 7 revisions with 61 more from 2015–2017 lying beside them, /show/patinfo/49537/01/002/diff listed none at all against 59 stored ones. All the drugs still existed; every Registration, Sequence and Migel::Item was reachable, so what was lost were superseded versions of the texts.
The cause, found 26.08.2026. PatinfoDocument#add_change_log_item compares the new diff against the existing ones to skip duplicates, and the rescue around that comparison read @change_log = [item] — so one entry that stumbled replaced the whole change log with the single new item. Comparing calls x.diff.to_s, which runs Diffy, which writes two tempfiles and shells out to diff, and that is where this data's encoding trouble lives. The Fachinfo version of the same method only logs and leaves the list alone, which is why the PI side lost five times as much. Found by snapshotting every reachable change_log id per registration, waiting for one update_today, and diffing: 2 entries gone out of 22 Patinfos stored, both PI de. Three earlier explanations had been reasoned out and all three died on measurement. Stopping is not restoring: 47 Patinfo objects over 40 registrations have no document left at all, and only 5 of those have an orphaned diff to rebuild from — the rest need a reparse from swissmedicinfo. See issue #446.
jobs/repair_orphaned_change_logs hangs them back on. Attribution comes out of the text itself: every Fach- and Patienteninformation ends with its registration number, and Diffy::Diff stores the complete old and new text, so the entry carries its own drug with it — which is also why the database is 19 GB. It runs in two steps so what gets written is a file somebody can read: --plan writes item, document, verdict, and --from-plan --apply attaches exactly those pairs without deciding anything again. The verdict is a check against the target: does the document's own text carry the same number? 20167 said yes, 721 said no — often a neighbour like 59410 against 59411, the same substance in another strength — and those were left alone. 20105 were reattached, taking the orphans down to 11340.
Note that odba_isolated_store on a document does not write its change_log: that is a separate ODBA object with its own odba_id, replaced by a stub when the document is dumped. The list has to be stored itself, before the document. Getting this wrong produces a run that reports success and changes nothing — and the same shape of mistake sits in the data already, as 7614 dangling edges where an object references a child that was never written (4091 of them on Package, 1120 on SimpleLanguage::Descriptions).
Every update of a Fach- or Patienteninformation builds a new document, and SimpleLanguage#update_values puts it where the old one stood. Nothing deleted the old one — with its twenty chapters. On some 6300 live Fachinfos that had grown to 200 000 superseded document versions and 3 953 085 chapters: 7.5 of 19 GB, and about 20 000 documents a year. The same mechanism that once cut the diff history loose.
Reachability was measured twice, and the second measurement is what made it safe to act. A breadth-first walk of object_connection from the named, the prefetchable and the index-referenced objects; and jobs/verify_chapter_reachability, which walks the live objects instead — registration to document to chapter. It reached 286 639 chapters and found not one of them in the set the first method calls unreachable. That the same search also returns exactly the 11 340 orphaned change-log items the August repair left behind is what makes it trustworthy.
The first verification run reported 1261 chapters and no contradiction at all, which looked like a clean bill of health and was worthless. reg.fachinfo hands back an ODBA::Stub, whose own instance variables are only @odba_id and its siblings — walking them finds nothing and reports calm; only a method call resolves the stub. And the chapters are not instance variables: document.chapters returns the names as symbols, and document.send(name) is what yields the chapter.
Three classes are never touched: anything ending in ChangeLogItem, and Array and Hash. 31 370 diffs hang on those old documents, 20 105 of them reattached to live documents in August and on the /diff/ pages today, and the Arrays are the change-log lists. ODBA::Cache#delete does not cascade — it deletes the object and cuts the references to it — but it also does a query per object and rewrites every referrer, which is hours for four million. So the deletion is batched SQL and therefore not reversible; a pg_dump -Fc comes first, 8.47 GB in a few minutes against the 63 the nightly plain-plus-bzip2 backup needs.
EphaInteraction went along, all 207 490 of them. The class was removed in March 2026 with "Replace EPHA CSV interactions with SDIF SQLite database"; defined?(ODDB::EphaInteraction) answers nil, so nothing could load one even in principle.
Seven stages, smallest class first, a restart and a page probe after each: 4 399 175 objects deleted, 7 114 259 → 2 715 084, and after a VACUUM FULL of two minutes the database went 19 GB → 9.66 GB. Every id is logged.
None of which would have lasted. ODDB::SupersededDocument deletes the replaced document and its chapters at the point where it is replaced, hooked into SimpleLanguage#update_values — the one place both Fachinfo and Patinfo pass through — and into replace_textinfo, which writes into the hash directly and would otherwise slip past. Nothing is lost by it, and that is the whole reason it is allowed: store_fachinfo carries the change log into the new document, and a Diffy::Diff stores the complete old and new text, so every entry is a whole version by itself. Only the newest is ever displayed. Two guards: the change log is taken out of the old document before it falls rather than trusting that delete does not cascade, and a chapter the successor also uses survives — the parser builds each chapter anew, but that is an assumption about someone else's code, and a wrong one would cost a live drug its text.
Verified with a reparse: document 61893094 with 19 chapters and 9 diffs before, document 61894333 with 19 new chapters and the same 9 diffs after, and the old document and its chapters gone from the database.
checkout deletes an object's children from the database. Until August 2026 it left the reference standing: Package#checkout called odba_delete on @sl_entry and @parts and cleared neither, so the package kept a stub to something that no longer existed. 3950 packages were in that state — every package.limitation and limitation_text raised ODBA::OdbaError, while price, deductible, generic type and name came from other fields and kept working, which is why nobody noticed. delete_sl_entry, twenty lines below in the same file, had always done it right. Sequence, Registration and Composition had the same shape and got the same fix. Note there is deliberately no odba_isolated_store: checkout runs immediately before or after the odba_delete of the object itself, and storing would recreate what was just deleted.
jobs/repair_dangling_references cleans up what the bug left behind — the edge list comes from SELECT c.origin_id, c.target_id FROM object_connection c WHERE NOT EXISTS (SELECT 1 FROM object o WHERE o.odba_id = c.target_id). It clears the ivar rather than restoring the target, because there is nothing to restore: of the 3924 affected packages only 135 are still in the BAG export, the other 3789 have left the Spezialitätenliste, so the reference was not just broken but moot. 4410 objects cleaned, dangling edges down from 7502 to 3258, and limitation answers on 300 of 300 sampled packages instead of failing on 292. Among them 257 @connection_keys on Substance — an ivar that appears nowhere in the code or in the odba gem any more.
What is left alone: the 1120 SimpleLanguage::Descriptions, whose broken links are hash entries rather than ivars. 6652 probes of the ATC guidelines they serve raised nothing, because Text::Document declares @descriptions as ODBA_SERIALIZABLE and no longer follows that path at all.
The SL export lists some packs twice under one Swissmedic number: once properly, with a GTIN and the pack price, and once without a GTIN, described as "1 Stk", carrying the price per unit. Both resolve to the same package through package_by_ikskey, both prices get written, and what survives is whichever came last. On 25.08.2026 Enflonsia stood at 354.60 instead of 3545.85 and Menveo at 37.75 instead of 188.75 — a tenth and a fifth of the real price. Encepur, FSME Immun Junior and Comirnaty happened to end on the right value, which is the same bug wearing a friendlier face.
BsvFhirPlugin#ghost_pack? drops a GTIN-less pack only when another pack with a GTIN shares its number. A blanket filter would have been wrong: 55 of the 10255 packs have no GTIN and 49 of those are the only pack under their number (Candesartan CPS and others), entirely legitimate. jobs/repair_ghost_pack_prices <gtin>=<preis> --apply fixes what is already stored — the filter prevents recurrence but does not repair. Reported to BAG; see issue #445.
Worth knowing for any price question: comparing all stored public prices against the GTIN-matched prices in the export takes a few minutes and gives a flat answer — 10010 of 10013 matched, one pack had no price here, and the two above were the only wrong ones.
bin/oddb_mail draft --to=… --subject=… --body=datei creates a draft in zdavatz@ywesee.com. Only ever a draft, never a send.
Two routes to a token. A service account would have to impersonate the mailbox, which needs domain-wide delegation in the Workspace console (admin.google.com → Security → Access and data control → API controls) and answers Client is unauthorized to retrieve access tokens until an admin adds the client id with the gmail.compose scope. An OAuth client asks the mailbox owner once instead and a refresh token carries it from there — no admin, no delegation. bin/oddb_mail authorize walks through it; the browser lands on http://localhost and fails to load, which is expected, and the code= is copied out of the address bar. GmailApi prefers OAuth and falls back to the service account, which exists anyway for the Search Console.
Two traps sit in the Google side of this. Its token endpoint returns error as a plain string while the other services return an object with message, so parsed.dig("error", "message") raises TypeError and hides the very message you need — the same line still sits in google_search_console.rb. And RCLConf raises NoMethodError for keys that are not set rather than returning nil, so the usual || default never runs. Secrets live in etc/oddb.yml, gitignored and mode 600.
jobs/rebuild_indices failed on six indices every night from at least January 2026 until 26.08.2026, and reported it to /dev/null — substance_index_atc had no table at all, because the index is dropped before the rebuild and the rebuild never finished. Three unrelated causes: a Latin-1 string that valid_encoding? calls valid, so the encoding guard passed it straight into a UTF-8 regexp; one corrupt object out of 41801 that cost two entire indices, because they call .substance on every element of active_agents; and an index definition resolving its target with active_sequences, a method that exists on the app and not on Substance. See issue #447.
Two things worth carrying away from that hunt. ODBA::Stub#is_a? answers from the declared class without resolving, so a broken reference passes every is_a? check in the codebase — only respond_to? resolves and tells the truth. And Array#delete cannot remove such an element, because comparing it is what resolves it: use object_id, which is on ODBA's no-override list, never equal?, which is not.
jobs/repair_broken_active_agents rebuilds a lost agent from the composition text by position, and refuses to act unless the intact neighbours sit where the text puts them.
On 1 September 2026 the Fach- and Patinfo texts of twenty registrations were not reparsed. Three separate faults had to line up, and the chain is more useful than any of them on its own.
rebuild_indices runs at 01:19 and failed on the last three indices in the definition file — oddb_package_name_with_size and its two siblings — with invalid reference (druby://127.0.0.1:10000). Util::Job.run registers the job's cache with the running application through system.peer_cache; when that reference goes stale, peer.reserve_next_id throws on the next next_id.
That it reached the caller at all was a mistake of mine in odba 1.2.2, on the first night under that version. 1.1.9 had peer.reserve_next_id id rescue DRb::DRbError — a modifier rescue with no class, absorbing every StandardError. 1.2.2 narrowed it to DRb::DRbError so that the OdbaDuplicateIdError a peer raises would reach the retry, which was the right intent; but invalid reference is a RangeError (drb.rb:372), not a DRbError. The dates line up: the Gemfile moved to 1.2.2 on 31.08. at 19:08, invalid reference appears in no cron log before that run, and all 35 indices were green from 27 to 31 August.
odba 1.2.3 catches OdbaDuplicateIdError first and re-raises it, and swallows every other StandardError from a peer the way 1.1.9 did. Two regression tests, one per direction; the stale-reference one fails against 1.2.2 with the production error. The lesson is the class, not the intent — narrowing a bare rescue means knowing everything it used to catch, and here the gap between a correct intent and an outage was three indices and a missed reparse.
An index without a table is a deferred index, and ODBA::Cache#setup fills it in the next process that starts — before that process does any work of its own. That is the amplification. update_today at 05:01 never reached its reparse; it died on the way up, in IndexCommon#fill → Package#name_with_size → Package#size → @parts.
One object was enough. Package 212202 (31862/035, Maliasin 100 mg) held a stub in @parts to a deleted object. Measured across every dangling edge in the database: one package out of 14581. The rest sit on Hash, SimpleLanguage::Descriptions and Array, which no index walks.
Repairing it made things worse on the first attempt, in exactly the way this file already describes twice. DanglingReferenceRepair set @parts = [] and stored the holder; the fresh [] is its own ODBA object and appears in the holder's dump only as a stub, so the dead reference 61866868 was swapped for a fresh dead reference 62051253. The job reported "1 cleaned" both times — only re-reading in a new process showed it. store now writes the list first and on its own. nil needs none of this because nil is not an object, which is why the 3950 @sl_entry cases of the August repair came through intact and only the Array and Hash ones did not.
And none of it was reported. jobs/update_today ended in a bare system(cmd) whose return value nobody looked at, so the job exited 0, the cron wrapper wrote (exit 0), and no mail went out — the same shape as the BSV import that reported success every morning for two months while importing nothing. It passes the status through now. Note also that update_fachinfo_rss_feeds ran normally five minutes later, because the crashed job had created the first index's table on its way down and it was no longer deferred: one job dies, the next is green again, which is how something like this survives without a log. Regression tests are in test/test_util/dangling_reference_repair.rb; the two load-bearing ones fail against the old code.
On 1 September 2026 neither scheduled import ran, and each was self-inflicted in a way worth remembering.
import_bsv at 08:00 ended with "Duplicate Job" and imported nothing — on the 1st, the day BAG publishes the Spezialitätenliste. A hand-started rebuild_indices was holding log/job.pid. The lock exists precisely to stop two importers running together, but it has no notion of precedence: anything long-running started in the 07:45–08:30 window whose name matches ^update|^import|^rebuild|^repair takes the day's import slot. The only sign is a "Duplicate Job" mail, and the exit status is 0.
import_swissmedic at 08:15 died with Could not find gem 'odba (= 1.2.3)', because the Gemfile entry had been raised before bundle install ran. In that window every bundle exec job dies instantly, before a line of our own code — the version is pinned exactly and bundler resolves before startup. So the order is not a matter of taste: publish the gem and bundle install first, then move the Gemfile entry, or do both together outside the cron windows. At least this one reported it (exit 1, cron mailed) while the BSV failure passed as exit 0 — the same difference as between a bare system(cmd) and a checked return value.
Both were re-run: BSV exit 0 at 10:11:12 with 40 price changes and 48 SL admissions plus the whole follower chain (SL update, Refdata, the 593-row med-drugs xls), Swissmedic exit 0 at 10:14:11 with a correct "no change" — Swissmedic still serves the file from 6 August, byte-identical at 3 274 214 bytes.
One reading tip from that comparison: the SL flags in the med-drugs export sat on the same four numbers from 2 to 25 August (38/40/1/19) and moved again on 1 September (48/22/4/34). That is not a fix taking effect, it is simply the first fresh BAG export in the window. An unchanged flag distribution across several runs means "no new export", not "broken".
inactive_date is meant to say when Swissmedic withdrew a registration. For 1555 of them it named the day we tidied up instead. On 27 September 2017 a one-off job (jobs/fix_expired_packages, committed the same day, removed as obsolete in November 2025) deactivated 2089 registrations at a stroke and stamped inactive_date: today on all of them. Cardiolite carried that date while it had already dropped out of the Swissmedic list on 15 April 2008 — 3452 days out. Count "withdrawals per year" from that and 2017 stands out with 2322 against 150–470 in every other year, while the years before it are correspondingly thin.
The evidence was already in the tree: data/xls holds 349 Swissmedic Packungen lists from 28 March 2008 onwards. A registration that drops out between two lists is gone by the later one at the latest — month-grained, and three to nine years closer than the cleanup day. jobs/fix_deregistration_dates reads them and corrects the date; dry by default, --apply writes and logs every change to log/fix_deregistration_dates-*.undo as iksnr, old, new.
The first glob was too narrow and cost two thirds of the coverage. Packungen-*.xlsx begins in 2014; Swissmedic delivered .xls until the end of 2013 and 108 of those are on disk. Including them raised the correctable dates from 348 to 1555 and dropped the undatable ones from 3995 to 2054.
Three written forms of the same number turn up, and the change happens inside the .xls run: text with the leading zero ("08537", until 2012), a float (274.0, 2013), and a plain number (450) in the .xlsx. 199 registrations begin with a zero — 00268 M-M-R-II, 00300 Sérocytol, 08671 Alka-Seltzer — so a value has to be padded back to five digits or they are silently lost. Speed matters too: RubyXL needs 16 seconds per file against 0.2 for reading the raw sheet XML out of the zip, which is the difference between an hour and a minute over 241 files. The old .xls is not a zip and needs Spreadsheet at about three seconds each, so the whole run takes some eight minutes.
BSV_per_*.xls goes back to 2003 and still cannot be used. Those list the Spezialitätenliste, not the authorisation, and leaving the reimbursement list is not being struck off. Measured for November 2008: 6621 registrations in the Packungen list against 3045 in the BSV one, so 3730 are authorised and simply not reimbursed. A date taken from there would be wrong for more than half of them.
The same caution applies within the Packungen lists: dropping out is not the same as being withdrawn. 41 active registrations have vanished from the list while their authorisation runs to 2028 or 2029 — all with zero packages, authorised but not marketed. So the job only ever corrects a date on a registration that is already inactive; it can neither deactivate nor reactivate anything. Four guards sit in front of every write: inactive? must hold, there must be no manual_inactive_date (60 have one, and that is a decision rather than a measurement), a date must already be there, and the existing date must be later than the evidence — an earlier one stands, because then somebody knew more than the list did.
A third source reaches further back than either list, and it was Zeno's idea: our own med-drugs exports. 439 med-drugs-*.xls files sit in data/xls from 13 October 2003 on, each a change list, and flag 14 in column A means :delete — the day the export reported a deletion. That yields 7437 registrations with a deletion date back to 2004-10-13, used as a fallback for anything that never appeared in a Packungen list: 699 more corrected, undatable down from 2054 to 1150, and the count still on 2017-09-27 from 734 to 286. Kontexin Retard carried 2017-09-27 and had been gone since 2004-10-13 — 4732 days. The Packungen list wins where both exist, being monthly and finer. One trap: med-drugs-20040115.xls is corrupt, and Spreadsheet bumps its mtime on every failed open, so an mtime-keyed cache never matched twice and rebuilt on every run; the stamp is file count plus newest name now, the dated names being immutable.
Result on 1 September 2026: 1555 corrected, then read back in a fresh process — 1555 of 1555 carry the new value, none wrong, none accidentally reactivated, the inactive total unchanged at 6824, and the count still sitting on 2017-09-27 down from 2089 to 734. Those 734 dropped out before the Packungen lists begin; the med-drugs exports dated most of them, and the expiry closed the rest.
The 286 that were left all carry an expiration_date, and every one of them falls in 2002–2013, clustered on 2006 and 2007. That is not an unrelated field here: expiration_date < 2017-08 is the very criterion jobs/fix_expired_packages used to select them in 2017, so for this cohort the expiry is the best evidence there is. CLEANUP_DAY in deregistration_dates.rb pins the fallback to 27 September 2017, and that narrowness is the point: the other large days are not artefacts. Measured across all 383 days that carry an inactive_date, the next biggest are 2012-08-07 (271), 2011-07-04 (127) and 2014-03-06 (91) — ordinary import days in the first week of a month, where the import genuinely noticed something had left the Packungen file. Those are datable from the snapshots and have long been in the stimmt bucket. 2003-12-31 (123) and 2004-12-31 (113) are the familiar year-end placeholders.
Result on 2 September 2026: 286 corrected, undatable from 1150 to 862, and the 27 September 2017 block from 331 to 45 — those 45 being exactly the ones with a manual_inactive_date, which the job never touches. Read back in a fresh process: 286 of 286 carry the new value, none wrong, none accidentally reactivated, the inactive total unchanged at 7443. The largest shifts land on 2002-12-31 and 2003-12-31 — expiry dates are partly year-end placeholders too, but a placeholder from the right decade beats a 2017 cleanup day by 5384 days.
The counter then lied, which is the trap in a fallback like this. Once the correction is written the expiry sits in inactive_date, current == CLEANUP_DAY is false, the fallback no longer applies — and the record drops back into nicht_datierbar. The dry run reported 1150 again instead of 864 and looked like entirely unfinished work. Counted separately now: 862 undatable plus 288 dated from the expiry. The 288 is two more than the 286 written — two registrations already carried their expiry as inactive_date — because the counter says "dated from the expiry", not "changed by us". 288 is not a count of changed records; that is what the undo log is for.
The 20 counted as frueher_als_beleg are a reporting lag, not damage (measured 2 September 2026, no action taken). All 20 come from the med-drugs exports, none from the Packungen lists, and none ever appeared in one. The stored date sits on a month boundary — 2004-12-31, 2005-12-01, 2006-01-01 — and the evidence lands 47 to 86 days later on a handful of export days (2006-01-25 six times, 2006-03-03 four, 2005-02-21 four): the evidence is the day the export reported the deletion, which is necessarily later than the event. They are the tail of one distribution rather than a class of their own — 98 registrations sit earlier than their evidence, spread continuously from 0 to 86 days with a median of 11, and only 20 exceed the arbitrary 45-day tolerance. The export cadence in 2004-2006 was 25 to 40 days, occasionally 42 to 48, which is exactly what turns a median of 11 into a 55 when a deletion is reported one cycle late.
Five of them sit on a bulk day and still stay put: four on 2004-12-31 (132 registrations that day) and Dérinox on 2005-12-08 (108). A 31 December carrying 132 is a year-end batch, not a placeholder — authorisations lapse at year end in numbers. The difference from 2017-09-27 is what settles it: there the script was found in the history and its criterion read, here there is no such evidence, the gain would be at most 52 days on five records, and the stored value is the more conservative one. Note that the 132 is partly of our own making — the expiry correction moved 19 registrations onto their 2004-12-31 expiry, from about 113 before.
It stays a derivation, not a measurement, and the comment in the code says so: the expiry is a lower bound on the day Swissmedic stopped listing the registration, not that day itself. An expiry later than 27 September 2017 is therefore discarded, and both the Packungen list and med-drugs still win wherever they have an answer.
The counterpart to correcting the dates: registrations that still count as active while they have dropped out of the Packungen lists, the oldest of them back in 2008. jobs/deactivate_vanished_registrations deactivates them with the date of the first list that missed them. State on 1 September 2026: 618 deactivated, active from 7490 to 6872, inactive from 6824 to 7442 — one more followed on 2 September, see below. Undo in log/deactivate_vanished-*.undo.
The first attempt deactivated 987, and 369 of those were wrong. They are Exportzulassungen — authorised for export, with no package for the Swiss market, and therefore never in the Packungen list at all. Reading only that list cannot tell them apart from a registration that was struck off. They were reverted to inactive_date = nil (safe: not one registration in the whole database carries an inactive_date in the future), and a fresh process confirms none of the 369 is still inactive.
The guard I had was expiration_date in the future, and it let them straight through: an export authorisation usually carries no expiry at all — "unbegrenzt" in the Präparateliste — and nil > today is false. Worse, a test asserted exactly that behaviour, so the bug sat in the tree as intent. It is turned around now with the reason written into it.
Präparateliste-latest.xlsx is the second source and answers the question directly: it lists the authorisations (6762) where the Packungen list holds only the packages (6299). Whatever is in it is authorised, full stop — no guessing from an expiry date. The file is mandatory: without it the job deactivates nothing. Its Exportzulassung / Hauptzulassung column is what PREPARATIONS_COLUMNS in the Swissmedic plugin reads as export_flag.
The lesson generalises: a list only answers the question it asks. "Not in the Packungen list" means "no package on the market"; for "no longer authorised" you need the authorisation list. The same mistake had already been avoided once, for the 41 registrations authorised to 2028 or 2029 — but only for those that carry an expiry date at all.
inactive_date means "no longer in the Swissmedic list on this day", not "the authorisation expired on this day". That is not a comment somewhere but what SwissmedicPlugin#deactivate does: when a registration falls out of the Packungen file the import writes inactive_date: @@today. So the reconstructed date is the same quantity the import writes daily. The end of the authorisation lives in expiration_date, a separate field — I briefly confused the two and was about to switch to expiration_date; the code that maintains the field settled it.
Four exclusions, each measured and each necessary. 6714 are in the Präparateliste and therefore authorised. 147 were never in a Packungen list, among them authorisations newer than the last snapshot (70893 Comirnaty XFG, 70418 Triofan Levodrop). 6 are still in the current Packungen list. And 5 vanished while their authorisation runs to 2028 or 2029, all with zero packages — authorised but not marketed.
A counter that merges two cases hides exactly this. deregistered_on returns nil both for "never listed" and for "still in the newest list"; the report called both nie_gelistet and showed 6462, so the fact that 6299 of them were a different case altogether was invisible. They are counted apart now.
Reading the 349 lists takes seven minutes, nearly all of it the 108 old .xls. The index is therefore cached beside them as data/xls/.packungen_index.tsv, keyed on file count plus newest mtime: 410 seconds to build, 0.09 to reuse, byte-identical. data/xls/ is gitignored.
There is no ongoing leak, and the note that used to stand here saying otherwise was my own measurement error. I had reported that the backlog keeps growing — 55 in 2026 alone, falling on every monthly publication. Subtract the export authorisations and nothing of it remains: 15 genuine against 16 export in 2022, 14 against 39 in 2023, 14 against 60 in 2024, 1 against 42 in 2025, and 0 against 55 in 2026. Every one of the 2026 cases was an export authorisation. The real backlog is historical — 2011 to 2019 carry 42, 47, 82, 51, 70, 58, 39, 60 and 34 — and it tails off to nothing. The Swissmedic import deactivates correctly and the backlog does not grow.
The job is still not finished once and for all, and the reason is the guard itself (2 September 2026). zulassung_laeuft holds back whatever is still authorised today — a comparison against Date.today, so every day it releases whatever lapses that day. The run on 2 September found exactly one: 68042 Levimid Acino (Acino Pharma, seven strengths, zero packages), expiry 2026-09-02, out of the Packungen list since 2022-09-02 and not in the Präparateliste. The day before it was still protected. This is not a leak in the import — that would never have caught it, because it dropped out in 2022 and the import only compares two consecutive downloads — but a gleaning the guard creates.
And that tail can be enumerated in full, which is why there is no cron job for it. The guard holds back exactly four today, all with zero packages and all with known due dates: 68457 Meropenem Sandoz i.v. 500 mg (expiry 2027-02-07, gone since 2024-06-07), 68319 Abirateron-Teva 500 mg (2027-05-01, since 2024-10-04), 68830 Ticagrelor Zentiva 60 mg (2028-02-15, since 2026-04-09) and 68986 Pemetrexed Ideogen 100 mg (2028-09-10, since 2025-05-07). Four records over two years, and the inflow is close to nil — one genuine case in 2025, none in 2026, the rest export authorisations. A quarterly run would be eight runs, six of them empty, plus a sudo install_crontab and the drift trap into /usr/local/sbin/. Run it by hand after each due date, or annually at most.
And here too inactive_date is the Packungen list, not the expiry. For 68042 the two lie four years apart — 2022-09-02 (off the market) against 2026-09-02 (authorisation ended) — and the job writes the first. I briefly took that for a bug, because it looks like the confusion that cost 369 export authorisations. It is not: SwissmedicPlugin#deactivate writes the same quantity every day, and going off the market while the authorisation still runs is the ordinary case. Active 6872 → 6871, inactive 7442 → 7443, read back in a fresh process.
The lesson is in the order of the questions. The striking number — 55 in the current year, 25 of them on a single day — looked like an active fault and was an artefact of how I had selected the rows. What resolved it was asking what these preparations actually are: the 25 from 5 June 2026 turned out to be 24 Dermapharm provocation tests and one Dienogest, every one with zero packages and no expiry date. When a block of similar numbers turns up, look at the objects before hunting for a cause.
Updater#wrap_update caught rescue Exception, so SignalException, Interrupt and SystemExit went through notify_error as well. A kill on a job therefore produced a mail headed "Error: swissmedic" with Plugin: ODDB::SwissmedicPlugin and a backtrace ending wherever the process happened to be — on 1 September 2026 inside the HTTP read of SwissmedicPlugin#get_latest_file. It reads as a broken Swissmedic import and was nothing of the kind: the run thirty seconds later finished with exit 0 and correctly established that Swissmedic still serves the file from 6 August, byte-identical at 3 274 214 bytes.
The three signal classes are now caught before rescue Exception, logged, and re-raised without a mail. Re-raising is the load-bearing half: the outer bare rescue takes only StandardError, so the signal still ends the job. Every genuine plugin exception is reported exactly as before. standardrb flags this as Lint/ShadowedException, which is a false positive — Exception sits after the three and still catches everything else — and is disabled in place with the reason.
No log says who sent the SIGTERM: neither cron nor oddb_cron healthcheck ever sends TERM to a job, the healthcheck knowing only the four rack services. That leaves the terminal, screen, or a kill by hand.
The interrupted download left nothing half-written, which is where this could have become expensive: a truncated Packungen-*.xlsx on one side of the comparison produces exactly the SwissmedicDiff SUSPECT mass diff that ruined the med-drugs export in July. get_latest_file only writes the dated file once it differs from Packungen-latest.xlsx.
Regression tests: test/test_util/updater.rb, five of them; the two load-bearing ones fail against the old code.
update_patinfo_rss_feeds was added to bin/oddb_cron on 30 August 2026 and had not run once by 1 September — there was no log/cron/update_patinfo_rss_feeds-*.log in any month. cron calls /usr/local/sbin/oddb_cron, and that copy still dated from 28 August, because bin/install_crontab --apply was never run afterwards. A job that does not run also does not fail, so nothing complained.
This is the same trap as etc/20_oddb.org.rack.conf against the live Apache configuration: the file in the tree is not the file in use. diff /usr/local/sbin/oddb_cron bin/oddb_cron is the whole check and belongs to every change to SCHEDULE.
Walking all 15018 registrations on 29.08.2026 turned up eight sequences carrying an ActiveAgent that no longer resolves — always in the BAG composition, never in the Swissmedic one, and the dead ids consecutive (55257061..63, 55257069..72) on the alphabetically first drugs. That is the handwriting of a job that worked through the list in order and died after the eighth.
The cause was still in the code, in both BSV importers. add_bag_composition_to_sequence cleared the old agents by hand and got three things wrong at once: it iterated the very list delete_active_agent deletes from, so with two agents the second is never visited; it called agent.odba_delete unconditionally, also when delete_active_agent had found nothing and left the reference standing, which is precisely how a dangling stub is made; and once one was in the list every comparison over it threw, because Array#delete compares with == and ActiveAgentCommon#== calls .substance on the other side. That throw disappeared into the method's own rescue, so the composition was simply never updated again. Both plugins call Composition#delete_all_active_agents now, and two regression tests fail against the old loop.
Nothing could be restored, and that is a property of the data: BAG compositions carry no source text, unlike the Swissmedic ones jobs/repair_broken_active_agents rebuilds from. The next BSV import writes new agents as soon as the drug is in the SL again. Repairing this made it worse once, which is the part worth remembering: setting @active_agents to [] and calling odba_isolated_store on the composition traded a harmless nil for a dangling Array stub, because the list is its own ODBA object with its own odba_id and is replaced by a stub in the holder's dump — it has to be stored first, by itself. The job's own run reported success; only re-reading in a fresh process showed it. And is_a?(Array) passes for that stub too.
A related nil that is not damage. @inactive_agents is nil on 6885 of 36112 compositions — all on the Swissmedic side, none on the BAG side, and only 12 of them on an active registration. Hilfsstoffe were introduced in 2015 and everything older never got the ivar; the reader answers [], so the pages are fine and normalising it would be 6885 writes for nothing. Two pieces of code did not cope, though. create_inactive_agent reaches past the reader (@inactive_agents.push) and raises NoMethodError there — and that error falls into the bare rescue in SwissmedicPlugin#update_all_sequence_info, which files it as "unparsed_composition": 54 such lines in August, 358 in July, not one carrying an exception. The guard that should have caught it was dead code — if composition.inactive_agents … else … can never take the else branch, because the reader returns [] and [] is truthy, so the replace in the if branch ran on a throwaway list and silently dropped what had been parsed. Worth knowing generally: replace on an agent list does not persist by itself, since odba_store on the holder only carries a child along while it is odba_unsaved?, which after a plain replace it is not. Composition#replace_active_agents and #replace_inactive_agents create the list where it is missing and write it themselves, before the holder.
A toggle in the top right corner switches between light and dark; the choice lives in the browser and follows prefers-color-scheme until someone decides otherwise. doc/resources/dark.css is an overlay keyed on html[data-theme="dark"], so without that attribute it does nothing and the light page is untouched — worth insisting on, because the file is embedded into every page. One overlay serves all eight flavor stylesheets, which share 249 of 253 selectors.
The overlay is generated from the source stylesheets — python3 bin/generate_dark_css.py doc/resources/gcc/oddb.css doc/resources/diff.css — and a test fails if any source rule has no dark counterpart. Enumerating by hand missed 77 rules, and a generic html[data-theme="dark"] a loses on specificity to a.subheading:link, which is why links stayed dark blue at 2.1:1 on a dark ground. Prefixing the source's own selector is always more specific than the original and later in the cascade.
The generator has two blind spots, both found in August 2026 by measuring contrast on rendered pages rather than reading CSS. It reads oddb.css and diff.css — so it never saw background: #eee on pre, which arrives via the @import of the vendored tundra.css and is where the Fachinfo's column-aligned tables live (1.2:1). And it cannot see inline styles written from Ruby: the interaction severity colours and the source, route and combination badges in sdif_interaction.rb are set as style="background-color: …", which left light text on pink at 1.7:1 and "Quelle: EPha.ch" at 1.1:1. Both are handled in the hand-written tail of dark.css; the inline case is covered by a general rule, which is safe because every inline background in the application is light. Figures in Fach- and Patienteninformation get a white plate rather than an inversion — in a medical figure the colours carry meaning.
A third correction came from reading rather than measuring: the diff lines kept their own colour, green text on a green ground and red on red, which clears the contrast bar at 7.4:1 and 8.3:1 and still reads badly line after line. The line keeps its ground — that is what carries the meaning — and the text moved to the reading colour, 9.1:1 and 10.8:1. The rule sits behind the generator marker, because everything above it is overwritten on the next run.
It deliberately does not use the existing colour choice (:styles, oddb-blue.css and siblings). That mechanism has been dead since the stylesheet started being inlined rather than linked: css_link substitutes oddb.css for oddb-<style>.css inside a <style> block whose content is the CSS itself, where the filename never appears — which is why picking blue or red today only changes the logo.
The page has declared width=device-width for years and then shipped a sixteen-column table with fixed pixel widths and not one media query — in none of the nine stylesheets. On a 390px phone the screen ended after the fourth column.
doc/resources/responsive.css is an overlay on the same pattern as dark.css: one file keyed on the shared class names, appended after oddb.css, covering all eight flavors. Two rules govern it and both are enforced by test/test_view/responsive.rb — every declaration sits inside a media query, so nothing outside the three breakpoints changes for anybody, and it sets no fixed colours, because it is embedded into every page and has to be right in dark mode too.
Three breakpoints. Desktop from 1025px stays dense — 13px instead of 12px, row padding 4→7px, tabular figures for prices, a header that stays put while scrolling; whoever compares prices wants rows, not air. Below 1025px the columns nobody reads at that width drop out. Below 640px each pack becomes a card.
The columns could not be addressed by position: result_list_components is overridden six times in lookandfeelwrapper.rb, so the public price is the ninth column in gcc and the eighth or fifth elsewhere. View::LookandfeelComponents#reorganize_components now appends the column key as a class — col-price_public means the same everywhere — and that one change is what makes the card layout possible at all.
Two of the fixes were specificity, the same trap dark mode fell into: table:has(…) > tbody > tr { display: block } scores (0,1,4) and beat tr:has(> th.col-name_base) { display: none } at (0,1,2), so the header row survived and the cards stacked as blocks. And a generic > td { display: block } at (0,1,3) quietly undid the column hiding one breakpoint up.
On a phone the zone navigation shows four of its eight entries — Medikamente, Interaktionen, MiGeL and Services. Eight broke onto three lines at 390px and pushed the search box off the first screen. They are picked by the name attribute, which is written in the code and is the same in all three languages, not by the label — "Apotheken" is "Pharmacies" in French. And only where the row is long enough to wrap: :nth-child(9) asks for at least five entries, so generika with three and oekk with two keep all of theirs. The four still did not fit at 410px against 393, until the cells gave up the 8px they carry on each side from oddb.css; the spacing is already inside the link, whose tap target is 40px tall.
The 16px on inputs is not taste: Safari zooms the whole page when a field under 16px takes focus, and does not zoom back out.
Two things CSS turned out not to be able to do here, and both are in doc/resources/javascript/compactdetail.js instead. A cell holding only is not :empty and CSS cannot look at text, so blank field pairs in the detail view and blank cells in the cards are hidden by script — 12 pairs on a detail page, 58 cells on a result list, 73 on the query-limit page. And flex-grow cannot force a line break: a flex container fills a line first and distributes leftover space afterwards, so the FI/PI marks kept sliding up beside the price whenever the name and price left room. An empty cell with flex-basis: 100% in front of the first mark is what actually breaks the line.
The recurring mistake was specificity, four times in one day. The rule that turns the nested layout tables into blocks carries a :not(:has(…)) per exception, and each one raises its specificity above anything further down that wants a different display — it beat the card rows twice, then the header rows, the chapter navigation and the footer. Exceptions belong in that rule, not in an answer to it. The other half of the same lesson: table.composite is both the page skeleton and real data tables, and the difference is that a data table has a th header row. Treating both alike turned a package list into a column of right-aligned single values.
Two more the phone found on 16 September 2026, both in the result list. The ATC band's rule td.atc a { white-space: nowrap } was written for the two marks WHO-DDD and DrugBank.ca, but the group title in front of them is a link too, so for "Multivitamine und andere Mineralstoffe, inkl. Kombinationen (A11AA03) 14 Präparate" it became a 744px word again and the whole list ran to 768px — pack size and price sat off screen. A short title like ponstan's never showed it. The nowrap now sits on a[href] and a.square only. And expired packs looked striped: oddb.css puts their #CCC on every td, which as flex items no longer fill the card, so the grey stood as bars behind name, size and substances with white gaps in between. The card takes the tone now, in currentColor, and the cells none.
Every link to a foreign host — ywesee.com, swissmedic.ch, drugbank.ca, the app stores, whatever a Fachinfo happens to link — opens in a new tab, with rel="noopener noreferrer", because without noopener the opened page can reach back through window.opener. One script rather than edits in the views: the links are built in over two hundred places, and a good part of them sits inside text that comes out of a parser. Own-host links stay put, absolute ones included; mailto:, tel: and bare anchors are skipped.
Clicking a feed on the home page used to land you in raw XML. jobs-generated feeds now also render as HTML in the site's own look and feel — /de/gcc/rss_html/channel/recall.rss, opened in a new tab from the feed title, while the feed icon still points at the XML.
ODDB::RssReader reads the file line by line, deliberately without an XML parser: fachinfo.rss is 237 MB, and a DOM of it per request would be a multiple of that. Descriptions are shown only when they are short and not a whole HTML document — the price feeds carry a rendered 28 KB detail page in every item.
Reading a whole year of Fachinfo took 52 seconds, and only 0.66 of those were the file. The reader ran four interpolated regular expressions against each of 3.7 million lines, and an interpolated pattern is recompiled on every call. Now it is one expression anchored at the start of the line, the indentation of a field is taken from the <item> line above it so the test per line is a prefix comparison, and a line too long to be a usable description is skipped before either. 2.3 seconds, all 2871 entries complete, and every one of the 2871 archive files still reads back exactly as many items as it holds.
The pages page by year, the way /recent_registrations/ does, and the title says which period is on screen. The entry page is the newest year, not a window of the newest fifty: update_price_feeds overwrites sl_introduction.rss, price_cut.rss and price_rise.rss with a single month on every run, and a page fed from those files showed July while January to June sat beside it in the monthly archives. The years in the chooser were right the whole time; only the page one lands on was wrong, which is the kind of fault that gets reported as missing data.
The history itself is written once and read from files. jobs/split_rss_archives cuts an existing feed into <name>-YYYY-MM.rss; jobs/build_price_archives reconstructs the three price channels from the packages' own price history in a single pass — 55 049 changes over 20 401 packages, back to the 1980s. It does not go through View::Rss::Package, which computes price_public(0) against (1), so an archive built through it showed today's price under a date from 2019. Note that the earliest entries are not events: the oldest price_cut is dated 1955 and names a Ramipril generic, and Ramipril was patented in 1981 — that is a placeholder valid_from. One per cent of the entries lie before 2000.
The Fachinfo yearly feeds were a different kind of wrong. update_yearly_fachinfo_feeds built them from app.sorted_fachinfos — the Fachinfos as they stand today, grouped by the year of their last revision. Each has exactly one revision date, so each landed in exactly one year: measured, 5053 distinct Fachinfos across all yearly files and three of them in more than one year. That is a partition of the stock, and it erodes, because a year keeps only what has not been touched since — February 2026's mass reparse pulled 2191 documents out of their earlier years, leaving 2025 with 66 entries against 2871 for 2026. A drug rewritten in 2019, 2022 and 2025 belonged in all three and appeared only in the last.
ODDB::FachinfoYearFeeds builds them from the documents' change_log instead — the same source the /diff/ pages read — one entry per Fachinfo and date, linking to the diff. Per Fachinfo and not per registration: a Fachinfo belongs to 2.3 registrations on average and the text changed once, not twice. jobs/build_fachinfo_year_feeds is the one-off, dry by default; jobs/update_fachinfo_rss_feeds uses the same class nightly.
Walking that graph needs odba_retire. A FachinfoDocument carries the whole text and a ChangeLogItem carries the old one and the new one — the double storage behind the 19 GB database. Reading 1500 Fachinfos stood at 2.7 GB and rising linearly; all 6287 would have passed ten. Neither ODBA.cache.clean nor nulling the instance variable helps, because the application's own @fachinfos hash and the memoized @sorted_fachinfos array hold every object alive. ODBA.cache.fetch_cache_entry(id)&.odba_retire(force: true) replaces the object with its Stub in everything that holds it — what ODBA itself does under memory pressure, and it writes nothing. Flat at 280 MB afterwards.
The patient information got the same treatment in August 2026, and the body is now shared: ODDB::YearFeeds holds it, ODDB::FachinfoYearFeeds and ODDB::PatinfoYearFeeds differ in where the documents come from and how an entry is linked. Both differences on the Patinfo side follow from the address of the diff. /show/patinfo/<iksnr>/<seqnr>/<ikscd>/diff/<date> needs the sequence and the package, not just the registration — Session::PI_DIFF_REGEXP resolves it that way — and a Patinfo hangs on sequences rather than registrations, so the walk goes registration → sequence → package. That is also why it walks the live registrations rather than app.patinfos: only a package that still exists yields a link the page can resolve. There is no big patinfo.rss in the shape of the 248 MB fachinfo.rss; the file beside the yearly ones carries the newest fifty changes, so the subscribe icon has something to point at. On the home page the feed sits in the RSS box directly under Fachinfo-Online.
Both boxes on the home page carry the number of changes in the newest month, read from app.rss_updates, which the two update jobs write. Before a job has run there is no number and none is shown.
Two things had to be got right on the way. The language is read out of descriptions directly and not through #de / #fr: SimpleLanguage#description falls back to the first language present when the one asked for is missing, so a document without a French text would have filed its German history in the French feed. And the walk retires the sequence and the package as well as the registration — a sequence is also held by its ATC class, and all of those sit in app.atc_classes, so retiring only the registration keeps sequence, package and Patinfo alive: measured 205 MB after 300 registrations and 624 MB after 2000, which extrapolates past four gigabytes. With the two extra lines it stays flat for most of the run and peaks at 1.3 GB.
Rendering a year page is not free, and that is not new either: 30 calls to the patinfo page took the app from 1437 to 1877 MB and 30 calls to the fachinfo one from 1437 to 1635 MB — about 14 MB and 6.6 MB per request, which Ruby keeps once it has grown the heap. Most of the difference is the file, patinfo's newest year being 2.9 MB against fachinfo's 1.9. It matters because the backends exit past their memory limits (4000 MB for the crawlers, 6144 MB otherwise) and are restarted by daemontools.
The backfill of 30.08.2026 wrote 33 files: 47 595 changes over 6427 Patinfos, 24 859 entries in German and 22 736 in French, en mirroring de. The history starts in 2017 and not earlier, because that is when the PI change log was introduced (67db6c50, 22.08.2017) — before that date nothing was ever written. That 2025 and 2026 hold 5717 and 8284 of the German entries against about 1500 a year before them is the other known fault showing through: until August 2026 a stumbling duplicate check in PatinfoDocument#add_change_log_item replaced the whole change log with the single new item, which is why 26 293 of 55 993 PI change log items were unreachable against 5154 of 73 090 on the Fachinfo side.
The yearly files stop carrying the full text, which is the point and the cost: fachinfo-2026.rss was 231 MB because every item embedded a rendered Fachinfo. The main fachinfo.rss still does, and that is the one Feedly subscribes to.
The XML is going nowhere. Feedly polls the five small channels about 290 times a day between them; those are real subscribers. fachinfo.rss gets two requests a day.
Adding an event needs three places, not one: the method on the state, require for the state class, and the name in EVENTS in src/util/validator.rb. Without the third, SBSM silently never triggers it and you get the previous page back with status 200.
ErrorDocument 500/503 /var/www/oddb.org/doc/resources/errors/appdown.html was a filesystem path where Apache expects a URL: a value with a leading / is a local URL, so under DocumentRoot /var/www/oddb.org/doc it resolved to …/doc/var/www/oddb.org/doc/…, failed, and fell through to the proxy that was down. It reads /resources/errors/appdown.html now, which the file-exists rewrite serves off disk.
That alone was not enough for ch.oddb.org, which proxies to Anubis rather than to the application. ErrorDocument fires only for errors Apache generates itself; with the application down and Anubis up, Apache receives a valid response from a healthy proxy and passes it through. ProxyErrorOverride is what changes that, and for a while it looked like a trade rather than a fix, because measuring with curl showed Anubis answering 500 — a status a narrow override cannot catch and a blanket one would take from the application's genuine 500s, the ones the Search Console work is about.
The measurement was misleading. Anubis answers curl with its own "administrator has misconfigured Anubis" page; a browser during an outage gets 502. So ProxyErrorOverride On 502 503 504 catches exactly this case and leaves real 500s alone. The status list needs Apache 2.4.52 or later — 2.4.58 here.
curl cannot verify the result, which is worth knowing before trying: Anubis answers any curl with its own 500 page whatever User-Agent is sent — measured with a full Chrome user-agent while the application was up. Only a real browser gets the 502 the override converts, so this is checked in a browser during a restart and not from the shell. It is the second time Anubis' 500 has sent an investigation the wrong way; for anything else, send a Googlebot user-agent, which bypasses it, or probe http://localhost:8012 directly.
We run 1.6.1. 1.6.2 and 1.6.3 change 29 lines between them — ChronoLogger to Logger, hpricot to nokogiri — and neither adds anything we need. But 1.6.3 rewrote _validate_html and lost the sanitising in the process: the unless that decided whether a tag is allowed has an empty body, so the method now only unwraps <body> and returns everything else verbatim. Fed <p>ok <b>fett</b> <script>alert(1)</script> …, 1.6.1 returns <p>ok <b>fett</b> alert(1) link</p> and 1.6.3 returns the input unchanged. We use it for :html_chapter (HTML = [:html_chapter] in src/util/validator.rb), the FI/PI chapter editor.
The one real argument for moving is that hpricot 0.8.6 is unmaintained and warns under Ruby 3.4. The way to take it is to fix _validate_html in sbsm first — it is our own gem.
Switched off in August 2026 in View::GoogleAdSenseMethods (ENABLED = false). The ads were not being used, but every page still carried the script from pagead2.googlesyndication.com, the <ins> block and the publisher id — and on a phone the iframe it pulled in was 504px wide, the last thing making the page scroll sideways.
The switch sits at the call site and not in the lookandfeel because SBSM::Lookandfeel#enabled?(event, default = true) computes default || ENABLED.include?(event): with the default true everything is on, and removing a key from an ENABLED list changes nothing.
The inline style on the <ins> was broken twice over — style="display:block height #{@height}px width {@width}px", without colons or semicolons, and with a {@width} that never had its #. The browser dropped the whole declaration, so not even display:block applied.
A published post cannot be read back, and that matters more than it sounds: commentary is read as "Little Text Format", where \|{}@[]()<>*_~ count as markup and are silently dropped. Our own URLs are full of underscores, so .../rss_html/channel/price_cut.rss went out as .../rsshtml/channel/pricecut.rss — a link into nowhere under a post that looked perfectly fine. GET /rest/posts/<urn> answers 403, so the only way to see what was published is to publish it: two forms side by side, visible to connections only, and look. # stays unescaped, or the hashtags stop being hashtags.
bin/oddb_linkedin authorize, then bin/oddb_linkedin post --body=text.txt --image=shot.png::alt text. Credentials live in the gitignored etc/oddb.yml.
Three things about this API are worth knowing before reaching for it. An image uploaded as application/octet-stream vanishes silently — 201, a valid urn, a post created, and no image; only image/png works. Scopes are stamped into a token when it is issued, so enabling a product or verifying the app does nothing for tokens that already exist, and they keep answering 403 with an empty body. And the profileId in a profile's page source is not the OpenID subject — only the sub from userinfo works as an author.
Posting is immediate; this API has no drafts, and images cannot be added to a post afterwards, which is what delete is for.
bundle exec ruby jobs/search_performance prints impressions, clicks, click rate and average position per property, broken down by day, query, page, country and device. Search Analytics is one of only four areas the Search Console API still covers — the Page Indexing report has none at all. Google lags two to three days, so a range ending today simply returns fewer days.
-
to run the Tests you need to do
- bundle install
- bundle exec ruby test/suite.rb — note that this exits 2, not 0, and has done for a while: two
google_ad_sensefailures left from switching AdSense off in August 2026, andTestPreferences#test_prefsraisingundefined method 'languages' for an instance of FlexMock. 3821 runs over 63 files. Judge a run by the counts and the failing names, not by the exit status. - bundle exec rspec spec/parslet_spec.rb # for parsing the compositions
- look at the index.html in the coverage directory
-
There are some Selenium/Watir based GUI integration tests. For details on how to use them have a look at tests_watir.textile. By default they are run using the gem headless (this can be overrriden by setting the environment variable ODDB_NO_HEADLESS)
-
There is test/wrk_performance.lua allows a stress test with a typical load. See test/wrk_performance.lua for details on howto run it
-
A test must never write into
test/data/.ODDB::TEST_DATA_DIRholds tracked fixtures andODDB::WORK_DIR(data4testsunder test) is the scratch directory — a setup that begins by wiping the latter withrm_rfhas already said which of the two it means.test_plugin/swissmedic.rbandtest_plugin/swissmedic_xlsx.rbstill handedTEST_DATA_DIRtoSwissmedicPlugin.newas its archive, the directory the plugin downloads into, and the fallout reached other test files:test/data/xls/Packungen-latest.xlsxis read bytest_plugin/xlsx_parser.rb,test_plugin/text_info.rbandtest/integration/common.rb, and one of the two overwrote it on every run while the other deleted it. Three tests inswissmedic.rbhad been failing on every checkout because of it — each asserts thatPackungen-latest.xlsxdoes not exist, whilesetupdeleted it and copied a fixture back onto it two lines later. Note the misleading assertion message, "A previous test did not clean up": it was the test's own setup. -
test/test_bin/holds shell tests for the shell scripts, whichtest/suite.rbdoes not cover — it runs minitest againstsrc/. Run them directly:sh test/test_bin/healthcheck.sh,sh test/test_bin/schedule.sh,sh test/test_bin/pg_backup.sh,sh test/test_bin/install_crontab.sh. They stubcurl,svc,svstat,idandinstallonPATH, so none of them touches/etc/service,/etcor the running application.
- To build your local documentation do:
- rdoc1.9 --op documentation
- Arbeitsregeln fuer Claude Code stehen in
CLAUDE.md(nur was ueberall gilt); die Vorfall-Historie je Teilsystem liegt unter.claude/skills/<name>/SKILL.mdund wird bei passender Aufgabe geladen. Neue Notizen gehoeren in den passenden Skill.
- sudo gem install oddb.org
- Zeno R.R. Davatz 2002-present
- Niklaus Giger 2012-present
- Masaomi Hatakeyama 2009-2011
- Yasuhiro Asaka 2008-2009
- Hannes Wyss 2002-2008
- Herve Robin. Thank you Herve!
- GPLv3.0
- doc/resources/javascript/qrcode.js — Licensed under the MIT license
The word "QR Code" is registered trademark of DENSO WAVE INCORPORATED
For Issues please open one on Github.