Received: from malur.postgresql.org ([217.196.149.56]) by arkaria.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.96) (envelope-from ) id 1wqCMI-0024yc-16 for pgsql-bugs@arkaria.postgresql.org; Sat, 01 Aug 2026 16:14:46 +0000 Received: from localhost ([127.0.0.1] helo=malur.postgresql.org) by malur.postgresql.org with esmtp (Exim 4.96) (envelope-from ) id 1wqCMH-001OwP-0R for pgsql-bugs@arkaria.postgresql.org; Sat, 01 Aug 2026 16:14:45 +0000 Received: from magus.postgresql.org ([2a02:c0:301:0:ffff::29]) by malur.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.96) (envelope-from ) id 1wqBHs-001Fo9-2Z for pgsql-bugs@lists.postgresql.org; Sat, 01 Aug 2026 15:06:08 +0000 Received: from mahout.postgresql.org ([2001:4800:3e1:1::227]) by magus.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.98.2) (envelope-from ) id 1wqBHq-00000001UQU-19Cg for pgsql-bugs@lists.postgresql.org; Sat, 01 Aug 2026 15:06:08 +0000 DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; d=postgresql.org; s=20171124; h=Message-ID:Date:Reply-To:Cc:From:To:Subject: Content-Transfer-Encoding:MIME-Version:Content-Type:Sender:Content-ID: Content-Description:In-Reply-To:References; bh=xuYakAFAXgWsmTyfi/K1AJkADx+1Rs/yGJSvLSAN7WU=; b=XwVD8LM4aNhSeSXGki7RYGyzXi w84pmAjqcwT+MKd6Plwblv9TA6TIJorRTs9Y0Mf3JblFVMHckxPh54ssCi9RXsfN9Z3cPj2nGK+iY Bn8JqoThUTzBXguGPUboJGHz8z9uGdTsGh66N1k3dtiQhm2pB9yfPEXSdEfyU+t6FuA7zj7OpWDvq qhzaY+PNSZhRCs6FbU7x7rCDIsJg6McBIuoB2LNVahap2AF4iZDbJ3UkwGaZg5tfKp7sEue66crPy 0iKvoaZnZ+YhvHqOtqWmdYHMqJvydUQFvM8wmZbb7kgoQEyrnnT5L8MfAK1fvE2Gr2C7uf9wMsL7Z xOVRqIng==; Received: from wrigleys.postgresql.org ([2a02:16a8:dc51::60]) by mahout.postgresql.org with esmtps (TLS1.3) tls TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (Exim 4.96) (envelope-from ) id 1wqBHo-0003hA-19 for pgsql-bugs@lists.postgresql.org; Sat, 01 Aug 2026 15:06:05 +0000 Received: from localhost ([127.0.0.1] helo=wrigleys.postgresql.org) by wrigleys.postgresql.org with esmtp (Exim 4.98.2) (envelope-from ) id 1wqBHn-0000000AVaH-1ui7 for pgsql-bugs@lists.postgresql.org; Sat, 01 Aug 2026 15:06:03 +0000 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: quoted-printable Subject: BUG #19594: to_char/jsonpath format cache serves a format tree parsed in the wrong strict-mode To: pgsql-bugs@lists.postgresql.org From: PG Bug reporting form Cc: malis@pgrust.com Reply-To: malis@pgrust.com, pgsql-bugs@lists.postgresql.org Date: Sat, 01 Aug 2026 15:05:36 +0000 Message-ID: <19594-5d9bdc019e3f7f6e@postgresql.org> X-Auto-Response-Suppress: All Auto-Submitted: auto-generated List-Id: List-Help: List-Subscribe: List-Post: List-Owner: List-Archive: Archived-At: Precedence: bulk The following bug has been logged on the website: Bug reference: 19594 Logged by: Michael Malis Email address: malis@pgrust.com PostgreSQL version: 18.3 Operating system: Debian (docker postgres:18.3, aarch64) Description: =20 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 =3D 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 =3D 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 =3D=3D=3D=3D=3D=3D=3D=3D 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 =3D=3D=3D=3D=3D=3D=3D=3D 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] =3D ent =3D (DCHCacheEntry *) 3868 MemoryContextAllocZero(TopMemoryContext, sizeof(DCHCacheEntry)); 3869 ent->valid =3D false; 3870 strlcpy(ent->str, str, DCH_CACHE_SIZE + 1); 3871 ent->std =3D std; <-- set here 3872 ent->age =3D (++DCHCounter); The recycle branch does not: 3855 old->valid =3D false; 3856 strlcpy(old->str, str, DCH_CACHE_SIZE + 1); 3857 old->age =3D (++DCHCounter); <-- old->std is never updat= ed 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) =3D=3D 0 && ent->std= =3D=3D 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.