BUG #19594: to_char/jsonpath format cache serves a format tree parsed in the wrong strict-mode

From: PG Bug reporting form <noreply(at)postgresql(dot)org>
To: pgsql-bugs(at)lists(dot)postgresql(dot)org
Cc: malis(at)pgrust(dot)com
Subject: BUG #19594: to_char/jsonpath format cache serves a format tree parsed in the wrong strict-mode
Date: 2026-08-01 15:05:36
Message-ID: 19594-5d9bdc019e3f7f6e@postgresql.org
Views: Whole Thread | Raw Message | Download mbox | Resend email
Thread:
Lists: pgsql-bugs

The following bug has been logged on the website:

Bug reference: 19594
Logged by: Michael Malis
Email address: malis(at)pgrust(dot)com
PostgreSQL version: 18.3
Operating system: Debian (docker postgres:18.3, aarch64)
Description:

Hey. This is the 9th bug I've found in a couple of days. I'm maybe 10% of
the way through the codebase so I expect to find a lot more. Should I be
submitting bugs in a different way to make it easier for you?

DCH_cache_getnew() in src/backend/utils/adt/formatting.c fails to reset the
per-entry "std" (SQL/JSON standard mode) flag when it recycles a cache
entry.
Because DCH_cache_search() matches on (str, std), a format tree that was
parsed in one strict-mode becomes reachable from the other.

The user-visible effect is that the same query, in the same session, returns
a different answer depending on what else that session has formatted
earlier. In particular jsonpath's .datetime(), which is required to use
SQL/JSON standard mode, can be handed a leniently-parsed tree and will then
accept format pictures the standard forbids.

All of the following runs in a single fresh session against a stock 18.3
server. No configuration changes are required.

-- 1. Control: in a fresh session, standard mode correctly rejects "z"
-- as a datetime format separator.
SELECT jsonb_path_query('"12z34"'::jsonb, '$.datetime("HH24zMI")');
ERROR: invalid datetime format separator: "z"

-- 2. Seed the cache with the picture "HH24MI" parsed in STANDARD mode
-- (std = true), via jsonpath.
SELECT jsonb_path_query('"1234"'::jsonb, '$.datetime("HH24MI")');
jsonb_path_query
------------------
"12:34:00"

-- 3. Fill the remaining cache slots. DCH_CACHE_ENTRIES is 20, so exactly
-- 19 further distinct pictures are needed to make the next miss evict.
-- (The to_char() result must actually be consumed, or the planner may
-- elide the calls and no cache entries are created.)
SELECT count(*) FROM generate_series(1,19) g
WHERE to_char(now(), 'HH24MI'||g) IS NOT NULL;
count
-------
19

-- 4. A to_char() call, i.e. LENIENT mode (std = false), with a new
-- picture. This misses, and evicts the entry created in step 2.
SELECT to_char(now(), 'HH24zMI');
to_char
---------
14z51

-- 5. Exactly the query from step 1. It now succeeds.
SELECT jsonb_path_query('"12z34"'::jsonb, '$.datetime("HH24zMI")');
jsonb_path_query
------------------
"12:34:00"

Step 5 is the defect. jsonpath .datetime() is standard mode and must reject
"z" as a separator, exactly as it did in step 1, but it is served the
lenient
tree left behind by step 4.

EXPECTED
========

Step 5 raises the same error as step 1:

ERROR: invalid datetime format separator: "z"

The result of a format operation must not depend on the session's cache
history.

ANALYSIS
========

src/backend/utils/adt/formatting.c. The cache entry carries the mode:

394 typedef struct
395 {
396 FormatNode format[DCH_CACHE_SIZE + 1];
397 char str[DCH_CACHE_SIZE + 1];
398 bool std;
399 bool valid;
400 int age;
401 } DCHCacheEntry;

DCH_cache_getnew() has two branches. The allocation branch sets std:

3867 DCHCache[n_DCHCache] = ent = (DCHCacheEntry *)
3868 MemoryContextAllocZero(TopMemoryContext,
sizeof(DCHCacheEntry));
3869 ent->valid = false;
3870 strlcpy(ent->str, str, DCH_CACHE_SIZE + 1);
3871 ent->std = std; <-- set here
3872 ent->age = (++DCHCounter);

The recycle branch does not:

3855 old->valid = false;
3856 strlcpy(old->str, str, DCH_CACHE_SIZE + 1);
3857 old->age = (++DCHCounter); <-- old->std is never updated
3858 /* caller is expected to fill format, then set valid */
3859 return old;

So a recycled entry keeps the std value of its previous occupant, while its
str and format are those of the new picture. DCH_cache_search() then
matches
on the stale flag:

3890 if (ent->valid && strcmp(ent->str, str) == 0 && ent->std ==
std)

DCH_cache_fetch() parses with (std ? STD_FLAG : 0), so the tree stored in
the
recycled slot is parsed in the *requested* mode but filed under the
*previous* occupant's mode. A later lookup in the previous occupant's mode
finds it and reuses it; a later lookup in the mode it was actually parsed
under misses and re-parses. Both directions are wrong; the reproducer above
shows the first.

Responses

Browse pgsql-bugs by date

  From Date Subject
Next Message Andrey Rachitskiy 2026-08-01 15:16:03 Re: BUG #19593: area(circle) silently returns Infinity instead of raising "value out of range: overflow"
Previous Message Zsolt Parragi 2026-08-01 11:11:02 Re: MERGE/SPLIT PARTITIONS issues/questions