Repository: GoogleCloudPlatform/pgadapter
Pull request #2079 (2024-07-16 20:33:51)

feat: register signal handlers for graceful shutdown
Register signal handlers for SIGTERM, SIGINT, and SIGQUIT to allow graceful shutdown, and add support for the SMART shutdown mode from PostgreSQL. The SMART shutdown mode allows the server to drain connections and shut down after all connections have closed.

Registering signal handlers is a best-effort and requires that the `sun.misc.Signal` and `sun.misc.SignalHandler` classes are available on the JVM where PGAdapter is running.

Updates #2040
Updates #1189



Patch:
diff --git a/benchmarks/latency-comparison/golang/Dockerfile b/benchmarks/latency-comparison/golang/Dockerfile
index 76e29f69..8b29639f 100644
--- a/benchmarks/latency-comparison/golang/Dockerfile
+++ b/benchmarks/latency-comparison/golang/Dockerfile
@@ -15,7 +15,7 @@
 # Use the offical golang image to create a binary.
 # This is based on Debian and sets the GOPATH to /go.
 # https://hub.docker.com/_/golang
-FROM golang:1.22-alpine as builder
+FROM golang:1.23-alpine as builder
 
 # Create and change to the app directory.
 WORKDIR /app
diff --git a/benchmarks/latency-comparison/golang/go.mod b/benchmarks/latency-comparison/golang/go.mod
index f4acea54..5a3b4aef 100644
--- a/benchmarks/latency-comparison/golang/go.mod
+++ b/benchmarks/latency-comparison/golang/go.mod
@@ -10,13 +10,13 @@ require (
 	github.com/montanaflynn/stats v0.7.0
 	github.com/testcontainers/testcontainers-go v0.33.0
 	golang.org/x/oauth2 v0.22.0
-	google.golang.org/api v0.195.0
+	google.golang.org/api v0.196.0
 )
 
 require (
 	cel.dev/expr v0.15.0 // indirect
 	cloud.google.com/go v0.115.1 // indirect
-	cloud.google.com/go/auth v0.9.1 // indirect
+	cloud.google.com/go/auth v0.9.3 // indirect
 	cloud.google.com/go/auth/oauth2adapt v0.2.4 // indirect
 	cloud.google.com/go/compute/metadata v0.5.0 // indirect
 	dario.cat/mergo v1.0.0 // indirect
@@ -36,7 +36,7 @@ require (
 	github.com/distribution/reference v0.6.0 // indirect
 	github.com/docker/go-connections v0.5.0 // indirect
 	github.com/docker/go-units v0.5.0 // indirect
-	github.com/envoyproxy/go-control-plane v0.12.0 // indirect
+	github.com/envoyproxy/go-control-plane v0.12.1-0.20240621013728-1eb8caab5155 // indirect
 	github.com/envoyproxy/protoc-gen-validate v1.0.4 // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
 	github.com/go-logr/logr v1.4.2 // indirect
@@ -47,7 +47,7 @@ require (
 	github.com/golang/protobuf v1.5.4 // indirect
 	github.com/google/s2a-go v0.1.8 // indirect
 	github.com/google/uuid v1.6.0 // indirect
-	github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect
+	github.com/googleapis/enterprise-certificate-proxy v0.3.3 // indirect
 	github.com/googleapis/gax-go/v2 v2.13.0 // indirect
 	github.com/jackc/chunkreader/v2 v2.0.1 // indirect
 	github.com/jackc/pgconn v1.14.3 // indirect
@@ -69,6 +69,7 @@ require (
 	github.com/opencontainers/go-digest v1.0.0 // indirect
 	github.com/opencontainers/image-spec v1.1.0 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
+	github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
 	github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
 	github.com/shirou/gopsutil/v3 v3.23.12 // indirect
 	github.com/shoenig/go-m1cpu v0.1.6 // indirect
@@ -77,11 +78,11 @@ require (
 	github.com/tklauser/numcpus v0.6.1 // indirect
 	github.com/yusufpapurcu/wmi v1.2.3 // indirect
 	go.opencensus.io v0.24.0 // indirect
-	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect
-	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
-	go.opentelemetry.io/otel v1.24.0 // indirect
-	go.opentelemetry.io/otel/metric v1.24.0 // indirect
-	go.opentelemetry.io/otel/trace v1.24.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 // indirect
+	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
+	go.opentelemetry.io/otel v1.29.0 // indirect
+	go.opentelemetry.io/otel/metric v1.29.0 // indirect
+	go.opentelemetry.io/otel/trace v1.29.0 // indirect
 	golang.org/x/crypto v0.26.0 // indirect
 	golang.org/x/net v0.28.0 // indirect
 	golang.org/x/sync v0.8.0 // indirect
@@ -90,7 +91,7 @@ require (
 	golang.org/x/time v0.6.0 // indirect
 	golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9 // indirect
 	google.golang.org/genproto/googleapis/api v0.0.0-20240725223205-93522f1f2a9f // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20240823204242-4ba0660f739c // indirect
-	google.golang.org/grpc v1.65.0 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
+	google.golang.org/grpc v1.66.0 // indirect
 	google.golang.org/protobuf v1.34.2 // indirect
 )
diff --git a/benchmarks/latency-comparison/golang/go.sum b/benchmarks/latency-comparison/golang/go.sum
index fac9247e..57390eb3 100644
--- a/benchmarks/latency-comparison/golang/go.sum
+++ b/benchmarks/latency-comparison/golang/go.sum
@@ -113,6 +113,8 @@ cloud.google.com/go/auth v0.8.0 h1:y8jUJLl/Fg+qNBWxP/Hox2ezJvjkrPb952PC1p0G6A4=
 cloud.google.com/go/auth v0.8.0/go.mod h1:qGVp/Y3kDRSDZ5gFD/XPUfYQ9xW1iI7q8RIRoCyBbJc=
 cloud.google.com/go/auth v0.9.1 h1:+pMtLEV2k0AXKvs/tGZojuj6QaioxfUjOpMsG5Gtx+w=
 cloud.google.com/go/auth v0.9.1/go.mod h1:Sw8ocT5mhhXxFklyhT12Eiy0ed6tTrPMCJjSI8KhYLk=
+cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
+cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
 cloud.google.com/go/auth/oauth2adapt v0.2.2 h1:+TTV8aXpjeChS9M+aTtN/TjdQnzJvmzKFt//oWu7HX4=
 cloud.google.com/go/auth/oauth2adapt v0.2.2/go.mod h1:wcYjgpZI9+Yu7LyYBg4pqSiaRkfEK3GQcpb7C/uyF1Q=
 cloud.google.com/go/auth/oauth2adapt v0.2.3 h1:MlxF+Pd3OmSudg/b1yZ5lJwoXCEaeedAguodky1PcKI=
@@ -746,6 +748,8 @@ github.com/envoyproxy/go-control-plane v0.10.3/go.mod h1:fJJn/j26vwOu972OllsvAgJ
 github.com/envoyproxy/go-control-plane v0.11.1-0.20230524094728-9239064ad72f/go.mod h1:sfYdkwUW4BA3PbKjySwjJy+O4Pu0h62rlqCMHNk+K+Q=
 github.com/envoyproxy/go-control-plane v0.12.0 h1:4X+VP1GHd1Mhj6IB5mMeGbLCleqxjletLK6K0rbxyZI=
 github.com/envoyproxy/go-control-plane v0.12.0/go.mod h1:ZBTaoJ23lqITozF0M6G4/IragXCQKCnYbmlmtHvwRG0=
+github.com/envoyproxy/go-control-plane v0.12.1-0.20240621013728-1eb8caab5155 h1:IgJPqnrlY2Mr4pYB6oaMKvFvwJ9H+X6CCY5x1vCTcpc=
+github.com/envoyproxy/go-control-plane v0.12.1-0.20240621013728-1eb8caab5155/go.mod h1:5Wkq+JduFtdAXihLmeTJf+tRYIT4KBc2vPXDhwVo1pA=
 github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 github.com/envoyproxy/protoc-gen-validate v0.6.7/go.mod h1:dyJXwwfPK2VSqiB9Klm1J6romD608Ba7Hij42vrOBCo=
 github.com/envoyproxy/protoc-gen-validate v0.9.1/go.mod h1:OKNgG7TCp5pF4d6XftA0++PMirau2/yoOwVac3AbF2w=
@@ -883,6 +887,8 @@ github.com/googleapis/enterprise-certificate-proxy v0.2.1/go.mod h1:AwSRAtLfXpU5
 github.com/googleapis/enterprise-certificate-proxy v0.2.3/go.mod h1:AwSRAtLfXpU5Nm3pW+v7rGDHp09LsPtGY9MduiEsR9k=
 github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs=
 github.com/googleapis/enterprise-certificate-proxy v0.3.2/go.mod h1:VLSiSSBs/ksPL8kq3OBOQ6WRI2QnaFynd1DCjZ62+V0=
+github.com/googleapis/enterprise-certificate-proxy v0.3.3 h1:QRje2j5GZimBzlbhGA2V2QlGNgL8G6e+wGo/+/2bWI0=
+github.com/googleapis/enterprise-certificate-proxy v0.3.3/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
 github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
@@ -1036,6 +1042,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
 github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
+github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
+github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
@@ -1120,20 +1128,30 @@ go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
 go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 h1:4Pp6oUg3+e/6M4C0A/3kJ2VYa++dsWVTtGgLVj5xtHg=
 go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0/go.mod h1:Mjt1i1INqiaoZOMGR1RIUJN+i3ChKoFRqzrRQhlkbs0=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI=
 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
+go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
 go.opentelemetry.io/otel v1.24.0 h1:0LAOdjNmQeSTzGBzduGe/rU4tZhMwL5rWgtp9Ku5Jfo=
 go.opentelemetry.io/otel v1.24.0/go.mod h1:W7b9Ozg4nkF5tWI5zsXkaKKDjdVjpD4oAt9Qi/MArHo=
+go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
+go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
 go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
 go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
 go.opentelemetry.io/otel/metric v1.24.0 h1:6EhoGWWK28x1fbpA4tYTOWBkPefTDQnb8WSGXlc88kI=
 go.opentelemetry.io/otel/metric v1.24.0/go.mod h1:VYhLe1rFfxuTXLgj4CBiyz+9WYBA8pNGJgDcSFRKBco=
+go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
+go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
 go.opentelemetry.io/otel/sdk v1.24.0 h1:YMPPDNymmQN3ZgczicBY3B6sf9n62Dlj9pWD3ucgoDw=
 go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg=
 go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y1YELI=
 go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
+go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
+go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
 go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
 go.opentelemetry.io/proto/otlp v0.15.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
 go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U=
@@ -1638,6 +1656,8 @@ google.golang.org/api v0.194.0 h1:dztZKG9HgtIpbI35FhfuSNR/zmaMVdxNlntHj1sIS4s=
 google.golang.org/api v0.194.0/go.mod h1:AgvUFdojGANh3vI+P7EVnxj3AISHllxGCJSFmggmnd0=
 google.golang.org/api v0.195.0 h1:Ude4N8FvTKnnQJHU48RFI40jOBgIrL8Zqr3/QeST6yU=
 google.golang.org/api v0.195.0/go.mod h1:DOGRWuv3P8TU8Lnz7uQc4hyNqrBpMtD9ppW3wBJurgc=
+google.golang.org/api v0.196.0 h1:k/RafYqebaIJBO3+SMnfEGtFVlvp5vSgqTUF54UN/zg=
+google.golang.org/api v0.196.0/go.mod h1:g9IL21uGkYgvQ5BZg6BAtoGJQIm8r6EgaAbpNey5wBE=
 google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
 google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
 google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
@@ -1797,6 +1817,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 h1:
 google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20240823204242-4ba0660f739c h1:Kqjm4WpoWvwhMPcrAczoTyMySQmYa9Wy2iL6Con4zn8=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20240823204242-4ba0660f739c/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
 google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
 google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -1842,6 +1864,8 @@ google.golang.org/grpc v1.64.1 h1:LKtvyfbX3UGVPFcGqJ9ItpVWW6oN/2XqTxfAnwRRXiA=
 google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
 google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc=
 google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ=
+google.golang.org/grpc v1.66.0 h1:DibZuoBznOxbDQxRINckZcUvnCEvrW9pcWIE2yF9r1c=
+google.golang.org/grpc v1.66.0/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
 google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0/go.mod h1:6Kw0yEErY5E/yWrBtf03jp27GLLJujG4z/JK95pnjjw=
 google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
diff --git a/pom.xml b/pom.xml
index 9589ab0d..53093413 100644
--- a/pom.xml
+++ b/pom.xml
@@ -214,6 +214,12 @@
       <artifactId>bcpkix-jdk18on</artifactId>
       <version>1.78.1</version>
     </dependency>
+    <dependency>
+      <groupId>org.codehaus.mojo</groupId>
+      <artifactId>animal-sniffer-annotations</artifactId>
+      <version>1.24</version>
+      <optional>true</optional>
+    </dependency>
 
     <!-- Test dependencies -->
     <dependency>
diff --git a/samples/dotnet/npgsql-sample/npgsql-sample.csproj b/samples/dotnet/npgsql-sample/npgsql-sample.csproj
index c171859e..41d70a09 100644
--- a/samples/dotnet/npgsql-sample/npgsql-sample.csproj
+++ b/samples/dotnet/npgsql-sample/npgsql-sample.csproj
@@ -10,7 +10,7 @@
 
     <ItemGroup>
       <PackageReference Include="Npgsql" Version="8.0.3" />
-      <PackageReference Include="Testcontainers" Version="3.9.0" />
+      <PackageReference Include="Testcontainers" Version="3.10.0" />
     </ItemGroup>
 
 </Project>
diff --git a/samples/golang/gorm/README.md b/samples/golang/gorm/README.md
index 155e0f1b..57c5b81a 100644
--- a/samples/golang/gorm/README.md
+++ b/samples/golang/gorm/README.md
@@ -139,16 +139,32 @@ FullName string `gorm:"->;type:GENERATED ALWAYS AS (coalesce(concat(first_name,'
 ```
 
 ### OnConflict Clauses
-`OnConflict` clauses are not supported by Cloud Spanner and should not be used. The following will
-therefore not work.
+`OnConflict` clauses require that all columns of the constraint that could potentially cause a conflict are included in
+the `OnConflict` clause. You should therefore specify `OnConflict` clauses with `DO NOTHING` like this:
 
 ```go
 user := User{
     ID:   1,
     Name: "User Name",
 }
-// OnConflict is not supported and this will return an error.
-db.Clauses(clause.OnConflict{DoNothing: true}).Create(&user)
+// OnConflict requires all columns to be specified.
+db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoNothing: true}).Create(&user)
+```
+
+`OnConflict` clauses that should update the existing row must include *ALL* columns as `my_col=excluded.my_col` clauses:
+
+```go
+singer := Singer{
+    BaseModel: BaseModel{ID: uuid.NewString()},
+    FirstName: sql.NullString{String: firstName, Valid: true},
+    LastName:  lastName,
+}
+// OnConflict clauses are supported on Spanner, but require that you specify all columns that should be checked for
+// potential conflicts, and *ALL* columns must be specified as AssignmentColumns (including the primary key).
+res := db.Clauses(clause.OnConflict{
+    Columns:   []clause.Column{{Name: "id"}},
+    DoUpdates: clause.AssignmentColumns([]string{"id", "first_name", "last_name", "active", "updated_at"}),
+}).Create(&singer)
 ```
 
 ### Auto-save Associations
diff --git a/samples/golang/gorm/create_data_model.sql b/samples/golang/gorm/create_data_model.sql
index 0dc26130..2c9ba6c5 100644
--- a/samples/golang/gorm/create_data_model.sql
+++ b/samples/golang/gorm/create_data_model.sql
@@ -13,7 +13,7 @@ create table if not exists singers (
              ELSE first_name || ' ' || last_name
         END) stored,
     active     boolean,
-    created_at timestamptz,
+    created_at timestamptz default current_timestamp,
     updated_at timestamptz
 );
 
@@ -24,7 +24,7 @@ create table if not exists albums (
     release_date     date,
     cover_picture    bytea,
     singer_id        varchar not null,
-    created_at       timestamptz,
+    created_at       timestamptz default current_timestamp,
     updated_at       timestamptz,
     constraint fk_albums_singers foreign key (singer_id) references singers (id)
 );
@@ -34,7 +34,7 @@ create table if not exists tracks (
     track_number bigint not null,
     title        varchar not null,
     sample_rate  float8 not null,
-    created_at   timestamptz,
+    created_at   timestamptz default current_timestamp,
     updated_at   timestamptz,
     primary key (id, track_number)
 )
@@ -45,7 +45,7 @@ create table if not exists venues (
     id          varchar not null primary key,
     name        varchar not null,
     description varchar not null,
-    created_at  timestamptz,
+    created_at  timestamptz default current_timestamp,
     updated_at  timestamptz
 );
 
@@ -56,7 +56,7 @@ create table if not exists concerts (
     name        varchar not null,
     start_time  timestamptz not null,
     end_time    timestamptz not null,
-    created_at  timestamptz,
+    created_at  timestamptz default current_timestamp,
     updated_at  timestamptz,
     constraint fk_concerts_venues  foreign key (venue_id)  references venues  (id),
     constraint fk_concerts_singers foreign key (singer_id) references singers (id),
@@ -79,7 +79,7 @@ create table if not exists ticket_sales (
     customer_name    varchar not null,
     price            decimal not null,
     seats            text[],
-    created_at       timestamptz,
+    created_at       timestamptz default current_timestamp,
     updated_at       timestamptz,
     deleted_at       timestamptz,
     constraint fk_ticket_sales_concerts foreign key (concert_id) references concerts (id)
diff --git a/samples/golang/gorm/go.mod b/samples/golang/gorm/go.mod
index a5d6aaed..cc649d49 100644
--- a/samples/golang/gorm/go.mod
+++ b/samples/golang/gorm/go.mod
@@ -3,7 +3,7 @@ module gorm
 go 1.21
 
 require (
-	github.com/GoogleCloudPlatform/pgadapter/wrappers/golang v0.0.0-20240618062230-6b6f887b294e
+	github.com/GoogleCloudPlatform/pgadapter/wrappers/golang v0.0.0-20240904094108-5a517d5836d1
 	github.com/google/uuid v1.6.0
 	github.com/jackc/pgtype v1.14.3
 	github.com/shopspring/decimal v1.4.0
@@ -26,7 +26,7 @@ require (
 	github.com/containerd/platforms v0.2.1 // indirect
 	github.com/cpuguy83/dockercfg v0.3.1 // indirect
 	github.com/distribution/reference v0.6.0 // indirect
-	github.com/docker/docker v27.1.1+incompatible // indirect
+	github.com/docker/docker v27.2.0+incompatible // indirect
 	github.com/docker/go-connections v0.5.0 // indirect
 	github.com/docker/go-units v0.5.0 // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -49,6 +49,7 @@ require (
 	github.com/moby/patternmatcher v0.6.0 // indirect
 	github.com/moby/sys/sequential v0.5.0 // indirect
 	github.com/moby/sys/user v0.1.0 // indirect
+	github.com/moby/sys/userns v0.1.0 // indirect
 	github.com/moby/term v0.5.0 // indirect
 	github.com/morikuni/aec v1.0.0 // indirect
 	github.com/opencontainers/go-digest v1.0.0 // indirect
@@ -66,7 +67,7 @@ require (
 	go.opentelemetry.io/otel/metric v1.24.0 // indirect
 	go.opentelemetry.io/otel/trace v1.24.0 // indirect
 	golang.org/x/crypto v0.24.0 // indirect
-	golang.org/x/oauth2 v0.21.0 // indirect
+	golang.org/x/oauth2 v0.22.0 // indirect
 	golang.org/x/sync v0.7.0 // indirect
 	golang.org/x/sys v0.21.0 // indirect
 	golang.org/x/text v0.16.0 // indirect
diff --git a/samples/golang/gorm/go.sum b/samples/golang/gorm/go.sum
index d9157c2b..3b1b3f2e 100644
--- a/samples/golang/gorm/go.sum
+++ b/samples/golang/gorm/go.sum
@@ -11,6 +11,10 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg6
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/GoogleCloudPlatform/pgadapter/wrappers/golang v0.0.0-20240618062230-6b6f887b294e h1:J7v/pF/ycWM5MkR8ypvW3in/bADLvVA5daW5jYc30CU=
 github.com/GoogleCloudPlatform/pgadapter/wrappers/golang v0.0.0-20240618062230-6b6f887b294e/go.mod h1:5nH+pq7AqOTRwLuieSbrizP112ZvIht+1KdGIYZZfQ0=
+github.com/GoogleCloudPlatform/pgadapter/wrappers/golang v0.0.0-20240902070506-94e968467df8 h1:FhbURatSGxlqdsiSU8eOBc/oDAtPpBuxvH2XYsQDCAw=
+github.com/GoogleCloudPlatform/pgadapter/wrappers/golang v0.0.0-20240902070506-94e968467df8/go.mod h1:AePg/g/kQCpx2r82bOQyF4HN/ZOX5SUcEuJz09MYpjA=
+github.com/GoogleCloudPlatform/pgadapter/wrappers/golang v0.0.0-20240904094108-5a517d5836d1 h1:3szvzwpCxOTbWFlUQVLuU+F7eH1VxgA0GIIxmC7wtKQ=
+github.com/GoogleCloudPlatform/pgadapter/wrappers/golang v0.0.0-20240904094108-5a517d5836d1/go.mod h1:AePg/g/kQCpx2r82bOQyF4HN/ZOX5SUcEuJz09MYpjA=
 github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
 github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
 github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
@@ -37,6 +41,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
 github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
 github.com/docker/docker v27.1.1+incompatible h1:hO/M4MtV36kzKldqnA37IWhebRA+LnqqcqDja6kVaKY=
 github.com/docker/docker v27.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
+github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
 github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
 github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
@@ -170,6 +176,8 @@ github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5
 github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo=
 github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg=
 github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU=
+github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
+github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
 github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
 github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
 github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
@@ -300,6 +308,8 @@ golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
 golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
 golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
 golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
+golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
+golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
diff --git a/samples/golang/gorm/sample.go b/samples/golang/gorm/sample.go
index cd654b35..e3a10cc6 100644
--- a/samples/golang/gorm/sample.go
+++ b/samples/golang/gorm/sample.go
@@ -47,8 +47,11 @@ type BaseModel struct {
 	// child tables in interleaved table hierarchies, as a missing primary key annotation here would then cause the
 	// primary key column defined on the child table to be the only primary key column.
 	ID string `gorm:"primaryKey;autoIncrement:false"`
-	// CreatedAt and UpdatedAt are managed automatically by gorm.
-	CreatedAt time.Time
+	// CreatedAt is marked as a read-only field ('->;') and with a default value.
+	// This instructs gorm to exclude this field from insert/update statements, and instead include it in a
+	// RETURNING clause so the generated value can be returned.
+	CreatedAt time.Time `gorm:"->;default:current_timestamp;"`
+	// UpdatedAt is managed automatically by gorm.
 	UpdatedAt time.Time
 }
 
@@ -56,9 +59,10 @@ type Singer struct {
 	BaseModel
 	FirstName sql.NullString
 	LastName  string
-	// FullName is generated by the database. The '->' marks this a read-only field. Preferably this field should also
-	// include a `default:(-)` annotation, as that would make gorm read the value back using a RETURNING clause. That is
-	// however currently not supported.
+	// FullName is generated by the database.
+	// The '->'  annotation marks this a read-only field.
+	// The `default:(-)` annotation marks the field as something that is generated by the database
+	// and instructs gorm to read the value back using a RETURNING clause.
 	FullName string `gorm:"->;type:GENERATED ALWAYS AS (coalesce(concat(first_name,' '::varchar,last_name))) STORED;default:(-);"`
 	Active   bool
 	Albums   []Album
@@ -482,19 +486,20 @@ func UpdateVenueDescription(db *gorm.DB) error {
 func FirstOrInitVenue(db *gorm.DB, name string) error {
 	venue := Venue{}
 	if err := db.Transaction(func(tx *gorm.DB) error {
-		// Use FirstOrInit to search and otherwise initialize a Venue entity.
-		// Note that we do not assign an ID in case the Venue was not found.
-		// This makes it possible for us to determine whether we need to call Create or Save, as Cloud Spanner does not
-		// support `ON CONFLICT UPDATE` clauses.
+		// Use FirstOrInit to search and otherwise initialize a Venue entity and then execute an insert-or-update
+		// statement.
 		if err := tx.FirstOrInit(&venue, Venue{Name: name}).Error; err != nil {
 			return err
 		}
 		venue.Description = `{"Capacity": 2000, "Location": "Europe/Berlin", "Country": "DE", "Type": "Arena"}`
 		// Create or update the Venue.
-		if venue.ID == "" {
-			return tx.Create(&venue).Error
-		}
-		return tx.Update("description", &venue).Error
+		// Note that Spanner requires that:
+		// 1. The potential conflict columns are all included in the ON CONFLICT clause (in this case 'id').
+		// 2. ALL columns are set as an assignment column (that is: included as a 'column=excluded.column' clause).
+		return tx.Clauses(clause.OnConflict{
+			Columns:   []clause.Column{{Name: "id"}},
+			DoUpdates: clause.AssignmentColumns([]string{"id", "name", "description", "updated_at"}),
+		}).Create(&venue).Error
 	}); err != nil {
 		fmt.Printf("Failed to create or update Venue %q: %v\n", name, err)
 		return err
@@ -670,7 +675,12 @@ func CreateSinger(db *gorm.DB, firstName, lastName string) (string, error) {
 		FirstName: sql.NullString{String: firstName, Valid: true},
 		LastName:  lastName,
 	}
-	res := db.Create(&singer)
+	// OnConflict clauses are supported on Spanner, but require that you specify all columns that should be checked for
+	// potential conflicts, and *ALL* columns must be specified as AssignmentColumns (including the primary key).
+	res := db.Clauses(clause.OnConflict{
+		Columns:   []clause.Column{{Name: "id"}},
+		DoUpdates: clause.AssignmentColumns([]string{"id", "first_name", "last_name", "active", "updated_at"}),
+	}).Create(&singer)
 	// FullName is automatically generated by the database and should be returned to the client by
 	// the insert statement.
 	expectedFullName := firstName + " " + lastName
@@ -692,7 +702,7 @@ func CreateAlbumWithRandomTracks(db *gorm.DB, singerId, albumTitle string, numTr
 	// We cannot include the Tracks that we want to create in the definition here, as gorm would then try to
 	// use an UPSERT to save-or-update the album that we are creating. Instead, we need to create the album first,
 	// and then create the tracks.
-	res := db.Create(&Album{
+	res := db.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "id"}}, DoNothing: true}).Create(&Album{
 		BaseModel:       BaseModel{ID: albumId},
 		Title:           albumTitle,
 		MarketingBudget: decimal.NullDecimal{Decimal: decimal.NewFromFloat(randFloat64(0, 10000000))},
diff --git a/samples/golang/pgx/go.mod b/samples/golang/pgx/go.mod
index f98df73d..d9bf3c0c 100644
--- a/samples/golang/pgx/go.mod
+++ b/samples/golang/pgx/go.mod
@@ -3,7 +3,7 @@ module pgadapter-pgx-sample
 go 1.21
 
 require (
-	github.com/GoogleCloudPlatform/pgadapter/wrappers/golang v0.0.0-20240618062230-6b6f887b294e
+	github.com/GoogleCloudPlatform/pgadapter/wrappers/golang v0.0.0-20240904094108-5a517d5836d1
 	github.com/jackc/pgx/v5 v5.6.0
 )
 
@@ -11,14 +11,15 @@ require (
 	cloud.google.com/go/compute/metadata v0.3.0 // indirect
 	dario.cat/mergo v1.0.0 // indirect
 	github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
-	github.com/Microsoft/go-winio v0.6.1 // indirect
-	github.com/Microsoft/hcsshim v0.11.4 // indirect
+	github.com/Microsoft/go-winio v0.6.2 // indirect
+	github.com/Microsoft/hcsshim v0.11.5 // indirect
 	github.com/cenkalti/backoff/v4 v4.2.1 // indirect
-	github.com/containerd/containerd v1.7.15 // indirect
+	github.com/containerd/containerd v1.7.18 // indirect
 	github.com/containerd/log v0.1.0 // indirect
+	github.com/containerd/platforms v0.2.1 // indirect
 	github.com/cpuguy83/dockercfg v0.3.1 // indirect
-	github.com/distribution/reference v0.5.0 // indirect
-	github.com/docker/docker v26.1.5+incompatible // indirect
+	github.com/distribution/reference v0.6.0 // indirect
+	github.com/docker/docker v27.2.0+incompatible // indirect
 	github.com/docker/go-connections v0.5.0 // indirect
 	github.com/docker/go-units v0.5.0 // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
@@ -30,13 +31,14 @@ require (
 	github.com/google/uuid v1.6.0 // indirect
 	github.com/jackc/pgpassfile v1.0.0 // indirect
 	github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
-	github.com/klauspost/compress v1.16.0 // indirect
+	github.com/klauspost/compress v1.17.4 // indirect
 	github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
 	github.com/magiconair/properties v1.8.7 // indirect
 	github.com/moby/docker-image-spec v1.3.1 // indirect
 	github.com/moby/patternmatcher v0.6.0 // indirect
 	github.com/moby/sys/sequential v0.5.0 // indirect
 	github.com/moby/sys/user v0.1.0 // indirect
+	github.com/moby/sys/userns v0.1.0 // indirect
 	github.com/moby/term v0.5.0 // indirect
 	github.com/morikuni/aec v1.0.0 // indirect
 	github.com/opencontainers/go-digest v1.0.0 // indirect
@@ -46,7 +48,7 @@ require (
 	github.com/shirou/gopsutil/v3 v3.23.12 // indirect
 	github.com/shoenig/go-m1cpu v0.1.6 // indirect
 	github.com/sirupsen/logrus v1.9.3 // indirect
-	github.com/testcontainers/testcontainers-go v0.31.0 // indirect
+	github.com/testcontainers/testcontainers-go v0.33.0 // indirect
 	github.com/tklauser/go-sysconf v0.3.12 // indirect
 	github.com/tklauser/numcpus v0.6.1 // indirect
 	github.com/yusufpapurcu/wmi v1.2.3 // indirect
@@ -54,13 +56,13 @@ require (
 	go.opentelemetry.io/otel v1.24.0 // indirect
 	go.opentelemetry.io/otel/metric v1.24.0 // indirect
 	go.opentelemetry.io/otel/trace v1.24.0 // indirect
-	golang.org/x/crypto v0.22.0 // indirect
-	golang.org/x/mod v0.16.0 // indirect
-	golang.org/x/oauth2 v0.21.0 // indirect
-	golang.org/x/sys v0.19.0 // indirect
-	golang.org/x/text v0.14.0 // indirect
-	golang.org/x/tools v0.13.0 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20230731190214-cbb8c96f2d6d // indirect
-	google.golang.org/grpc v1.58.3 // indirect
+	golang.org/x/crypto v0.24.0 // indirect
+	golang.org/x/mod v0.17.0 // indirect
+	golang.org/x/oauth2 v0.22.0 // indirect
+	golang.org/x/sys v0.21.0 // indirect
+	golang.org/x/text v0.16.0 // indirect
+	golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect
+	google.golang.org/grpc v1.64.1 // indirect
 	google.golang.org/protobuf v1.33.0 // indirect
 )
diff --git a/samples/golang/pgx/go.sum b/samples/golang/pgx/go.sum
index 59c9f494..8c71658a 100644
--- a/samples/golang/pgx/go.sum
+++ b/samples/golang/pgx/go.sum
@@ -8,16 +8,26 @@ github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOEl
 github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
 github.com/GoogleCloudPlatform/pgadapter/wrappers/golang v0.0.0-20240618062230-6b6f887b294e h1:J7v/pF/ycWM5MkR8ypvW3in/bADLvVA5daW5jYc30CU=
 github.com/GoogleCloudPlatform/pgadapter/wrappers/golang v0.0.0-20240618062230-6b6f887b294e/go.mod h1:5nH+pq7AqOTRwLuieSbrizP112ZvIht+1KdGIYZZfQ0=
+github.com/GoogleCloudPlatform/pgadapter/wrappers/golang v0.0.0-20240902070506-94e968467df8 h1:FhbURatSGxlqdsiSU8eOBc/oDAtPpBuxvH2XYsQDCAw=
+github.com/GoogleCloudPlatform/pgadapter/wrappers/golang v0.0.0-20240902070506-94e968467df8/go.mod h1:AePg/g/kQCpx2r82bOQyF4HN/ZOX5SUcEuJz09MYpjA=
+github.com/GoogleCloudPlatform/pgadapter/wrappers/golang v0.0.0-20240904094108-5a517d5836d1 h1:3szvzwpCxOTbWFlUQVLuU+F7eH1VxgA0GIIxmC7wtKQ=
+github.com/GoogleCloudPlatform/pgadapter/wrappers/golang v0.0.0-20240904094108-5a517d5836d1/go.mod h1:AePg/g/kQCpx2r82bOQyF4HN/ZOX5SUcEuJz09MYpjA=
 github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migciow=
 github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
+github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
+github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
 github.com/Microsoft/hcsshim v0.11.4 h1:68vKo2VN8DE9AdN4tnkWnmdhqdbpUFM8OF3Airm7fz8=
 github.com/Microsoft/hcsshim v0.11.4/go.mod h1:smjE4dvqPX9Zldna+t5FG3rnoHhaB7QYxPRqGcpAD9w=
+github.com/Microsoft/hcsshim v0.11.5/go.mod h1:MV8xMfmECjl5HdO7U/3/hFVnkmSBjAjmA09d4bExKcU=
 github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
 github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
 github.com/containerd/containerd v1.7.15 h1:afEHXdil9iAm03BmhjzKyXnnEBtjaLJefdU7DV0IFes=
 github.com/containerd/containerd v1.7.15/go.mod h1:ISzRRTMF8EXNpJlTzyr2XMhN+j9K302C21/+cr3kUnY=
+github.com/containerd/containerd v1.7.18/go.mod h1:IYEk9/IO6wAPUz2bCMVUbsfXjzw5UNP5fLz4PsUygQ4=
 github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
 github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
+github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
+github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
 github.com/cpuguy83/dockercfg v0.3.1 h1:/FpZ+JaygUR/lZP2NlFI2DVfrOEMAIKP5wWEJdoYe9E=
 github.com/cpuguy83/dockercfg v0.3.1/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
 github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
@@ -27,8 +37,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/distribution/reference v0.5.0 h1:/FUIFXtfc/x2gpa5/VGfiGLuOIdYa1t65IKK2OFGvA0=
 github.com/distribution/reference v0.5.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
+github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
+github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
 github.com/docker/docker v26.1.5+incompatible h1:NEAxTwEjxV6VbBMBoGG3zPqbiJosIApZjxlbrG9q3/g=
 github.com/docker/docker v26.1.5+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
+github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
+github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
 github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
 github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
@@ -66,6 +80,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI
 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 github.com/klauspost/compress v1.16.0 h1:iULayQNOReoYUe+1qtKOqw9CwJv3aNQu8ivo7lw1HU4=
 github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE=
+github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4=
+github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
 github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
 github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
 github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
@@ -78,6 +94,8 @@ github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5
 github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo=
 github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg=
 github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU=
+github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
+github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
 github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
 github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
 github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
@@ -112,6 +130,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/testcontainers/testcontainers-go v0.31.0 h1:W0VwIhcEVhRflwL9as3dhY6jXjVCA27AkmbnZ+UTh3U=
 github.com/testcontainers/testcontainers-go v0.31.0/go.mod h1:D2lAoA0zUFiSY+eAflqK5mcUx/A5hrrORaEQrd0SefI=
+github.com/testcontainers/testcontainers-go v0.33.0 h1:zJS9PfXYT5O0ZFXM2xxXfk4J5UMw/kRiISng037Gxdw=
+github.com/testcontainers/testcontainers-go v0.33.0/go.mod h1:W80YpTa8D5C3Yy16icheD01UTDu+LmXIA2Keo+jWtT8=
 github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
 github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
 github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
@@ -141,10 +161,13 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
 golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
+golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
+golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
 golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -153,6 +176,8 @@ golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
 golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
 golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
 golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
+golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA=
+golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -170,12 +195,16 @@ golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
 golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
+golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
 golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
 golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
+golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
+golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
 golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
 golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -184,6 +213,7 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
 golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ=
 golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -193,8 +223,10 @@ google.golang.org/genproto/googleapis/api v0.0.0-20230711160842-782d3b101e98 h1:
 google.golang.org/genproto/googleapis/api v0.0.0-20230711160842-782d3b101e98/go.mod h1:rsr7RhLuwsDKL7RmgDDCUc6yaGr1iqceVb5Wv6f6YvQ=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20230731190214-cbb8c96f2d6d h1:pgIUhmqwKOUlnKna4r6amKdUngdL8DrkpFeV8+VBElY=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20230731190214-cbb8c96f2d6d/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237/go.mod h1:WtryC6hu0hhx87FDGxWCDptyssuo68sk10vYjF+T9fY=
 google.golang.org/grpc v1.58.3 h1:BjnpXut1btbtgN/6sp+brB2Kbm2LjNXnidYujAVbSoQ=
 google.golang.org/grpc v1.58.3/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0=
+google.golang.org/grpc v1.64.1/go.mod h1:hiQF4LFZelK2WKaP6W0L92zGHtiQdZxk8CrSdvyjeP0=
 google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
 google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/samples/java/liquibase/pom.xml b/samples/java/liquibase/pom.xml
index 7b4b4d13..36f2ab5e 100644
--- a/samples/java/liquibase/pom.xml
+++ b/samples/java/liquibase/pom.xml
@@ -62,7 +62,7 @@
         <plugin>
           <groupId>org.liquibase</groupId>
           <artifactId>liquibase-maven-plugin</artifactId>
-          <version>4.29.1</version>
+          <version>4.29.2</version>
           <configuration>
             <propertyFile>liquibase.properties</propertyFile>
             <outputChangeLogFile>changelog-master.xml</outputChangeLogFile>
diff --git a/samples/nodejs/knex/src/index.ts b/samples/nodejs/knex/src/index.ts
index f3bcb1f0..5789a997 100644
--- a/samples/nodejs/knex/src/index.ts
+++ b/samples/nodejs/knex/src/index.ts
@@ -130,7 +130,8 @@ async function createVenuesAndConcerts(knex: Knex) {
       name: 'Avenue Park',
       description: '{"Capacity": 5000, "Location": "New York", "Country": "US"}'
     } as Venue;
-    await tx.insert<Venue>(venue).into('venues');
+    // Use onConflict(...).merge() to execute an insert-or-update.
+    await tx.insert<Venue>(venue).onConflict('id').merge().into('venues');
     const concert = {
       id: randomUUID(),
       name: 'Avenue Park Open',
@@ -139,7 +140,8 @@ async function createVenuesAndConcerts(knex: Knex) {
       start_time: new Date('2023-02-01T20:00:00-05:00'),
       end_time:   new Date('2023-02-02T02:00:00-05:00'),
     } as Concert;
-    await tx.insert<Concert>(concert).into('concerts');
+    // Use onConflict(...).merge() to execute an insert-or-update.
+    await tx.insert<Concert>(concert).onConflict('id').merge().into('concerts');
 
     // TicketSale uses an auto-generated primary key, so we don't need to supply a value for it.
     // The primary key value is generated by a bit-reversed sequence.
diff --git a/samples/nodejs/sequelize/models/models.ts b/samples/nodejs/sequelize/models/models.ts
index 4fc43dcd..c8fb34d6 100644
--- a/samples/nodejs/sequelize/models/models.ts
+++ b/samples/nodejs/sequelize/models/models.ts
@@ -112,13 +112,21 @@ export function initModels(sequelize: Sequelize) {
     active: {
       type: DataTypes.BOOLEAN,
     },
+    // createdAt is automatically set by Spanner using a DEFAULT constraint.
     createdAt: {
       type: DataTypes.DATE,
     },
+    // updatedAt is automatically managed by Sequelize client-side.
     updatedAt: {
       type: DataTypes.DATE,
     }
-  }, {sequelize});
+  }, {
+    sequelize,
+    // Disable the 'createdAt' handling of Sequelize and let Spanner handle that using a DEFAULT
+    // constraint. This allows us to use insert-or-update statements where the 'createdAt' is only
+    // set by Spanner when a new row is inserted.
+    createdAt: false
+  });
 
   Album.init({
     id: {
@@ -135,13 +143,21 @@ export function initModels(sequelize: Sequelize) {
     marketingBudget: {
       type: DataTypes.DECIMAL,
     },
+    // createdAt is automatically set by Spanner using a DEFAULT constraint.
     createdAt: {
       type: DataTypes.DATE,
     },
+    // updatedAt is automatically managed by Sequelize client-side.
     updatedAt: {
       type: DataTypes.DATE,
     }
-  }, {sequelize});
+  }, {
+    sequelize,
+    // Disable the 'createdAt' handling of Sequelize and let Spanner handle that using a DEFAULT
+    // constraint. This allows us to use insert-or-update statements where the 'createdAt' is only
+    // set by Spanner when a new row is inserted.
+    createdAt: false
+  });
 
   // Note: Track is interleaved in Album.
   Track.init({
@@ -163,13 +179,21 @@ export function initModels(sequelize: Sequelize) {
     sampleRate: {
       type: DataTypes.DOUBLE,
     },
+    // createdAt is automatically set by Spanner using a DEFAULT constraint.
     createdAt: {
       type: DataTypes.DATE,
     },
+    // updatedAt is automatically managed by Sequelize client-side.
     updatedAt: {
       type: DataTypes.DATE,
     }
-  }, {sequelize});
+  }, {
+    sequelize,
+    // Disable the 'createdAt' handling of Sequelize and let Spanner handle that using a DEFAULT
+    // constraint. This allows us to use insert-or-update statements where the 'createdAt' is only
+    // set by Spanner when a new row is inserted.
+    createdAt: false
+  });
 
   Venue.init({
     id: {
@@ -183,13 +207,21 @@ export function initModels(sequelize: Sequelize) {
     description: {
       type: DataTypes.STRING,
     },
+    // createdAt is automatically set by Spanner using a DEFAULT constraint.
     createdAt: {
       type: DataTypes.DATE,
     },
+    // updatedAt is automatically managed by Sequelize client-side.
     updatedAt: {
       type: DataTypes.DATE,
     }
-  }, {sequelize})
+  }, {
+    sequelize,
+    // Disable the 'createdAt' handling of Sequelize and let Spanner handle that using a DEFAULT
+    // constraint. This allows us to use insert-or-update statements where the 'createdAt' is only
+    // set by Spanner when a new row is inserted.
+    createdAt: false
+  });
 
   Concert.init({
     id: {
@@ -212,13 +244,21 @@ export function initModels(sequelize: Sequelize) {
     endTime: {
       type: DataTypes.DATE,
     },
+    // createdAt is automatically set by Spanner using a DEFAULT constraint.
     createdAt: {
       type: DataTypes.DATE,
     },
+    // updatedAt is automatically managed by Sequelize client-side.
     updatedAt: {
       type: DataTypes.DATE,
     }
-  }, {sequelize})
+  }, {
+    sequelize,
+    // Disable the 'createdAt' handling of Sequelize and let Spanner handle that using a DEFAULT
+    // constraint. This allows us to use insert-or-update statements where the 'createdAt' is only
+    // set by Spanner when a new row is inserted.
+    createdAt: false
+  });
 
   TicketSale.init({
     id: {
@@ -238,13 +278,21 @@ export function initModels(sequelize: Sequelize) {
     seats: {
       type: DataTypes.ARRAY(DataTypes.STRING),
     },
+    // createdAt is automatically set by Spanner using a DEFAULT constraint.
     createdAt: {
       type: DataTypes.DATE,
     },
+    // updatedAt is automatically managed by Sequelize client-side.
     updatedAt: {
       type: DataTypes.DATE,
     }
-  }, {sequelize})
+  }, {
+    sequelize,
+    // Disable the 'createdAt' handling of Sequelize and let Spanner handle that using a DEFAULT
+    // constraint. This allows us to use insert-or-update statements where the 'createdAt' is only
+    // set by Spanner when a new row is inserted.
+    createdAt: false
+  });
 
   Singer.hasMany(Album);
   Album.belongsTo(Singer);
diff --git a/samples/nodejs/sequelize/src/index.ts b/samples/nodejs/sequelize/src/index.ts
index 6194726e..fae0357f 100644
--- a/samples/nodejs/sequelize/src/index.ts
+++ b/samples/nodejs/sequelize/src/index.ts
@@ -16,7 +16,12 @@ import {createDataModel, startPGAdapter} from './init'
 import {Album, Concert, initModels, Singer, TicketSale, Track, Venue} from '../models/models';
 import {literal, Op, QueryTypes, Sequelize} from "sequelize";
 import {randomInt} from "crypto";
-import {randomAlbumTitle, randomFirstName, randomLastName, randomTrackTitle} from "./random";
+import {
+  randomAlbumTitle,
+  randomFirstName,
+  randomLastName,
+  randomTrackTitle
+} from "./random";
 
 async function main() {
   // Start PGAdapter in a Docker test container.
@@ -69,6 +74,9 @@ async function main() {
   await createVenuesAndConcerts(sequelize);
   
   await staleRead(sequelize);
+  
+  // Insert-or-update a random singer.
+  await insertOrUpdateSinger(sequelize);
 
   // Close the sequelize connection pool and shut down PGAdapter.
   await sequelize.close();
@@ -261,6 +269,39 @@ async function createVenuesAndConcerts(sequelize: Sequelize) {
   console.log("Finished creating venues and concerts");
 }
 
+// Randomly insert a new singer or update an existing singer using an insert-or-update statement.
+async function insertOrUpdateSinger(sequelize: Sequelize) {
+  console.log("Executing an insert-or-update statement for a Singer record...");
+  // Randomly select an existing Singer ID or generate a random new one.
+  // This will make this function insert a new Singer row or update
+  // an existing one at random.
+  const update = randomInt(10) < 5;
+  const id = update
+    ? (await Singer.findOne({limit: 1})).id
+    : randomInt(100);
+  // 'Upsert' (insert-or-update) a Singer by setting a value for the 'id' column and then
+  // calling the 'upsert' method. This will generate an INSERT ... ON CONFLICT (id) UPDATE ...
+  // statement. Note that Spanner requires that ALL columns that are part of the INSERT statement
+  // are also part of the UPDATE clause. This means that you cannot use 'upsert' in combination
+  // with 'createdAt' columns that are managed by Sequelize. Instead, you should let Spanner
+  // automatically set the 'createdAt' column by adding a DEFAULT constraint for the 'createdAt'
+  // column, and disabling the auto-management of 'createdAt' in Sequelize. You can do this by
+  // 1. Defining the column like this: "createdAt" timestamptz default current_timestamp
+  // 2. Add 'createdAt: false' to the options that are used to initalize the model.
+  //
+  // See also the init.ts and model.ts files in this folder.
+  const [singer] = await Singer.upsert({
+    id: id,
+    firstName: randomFirstName(),
+    lastName: randomLastName()},
+  );
+  if (update) {
+    console.log(`Updated singer ${singer.id}`);
+  } else {
+    console.log(`Inserted singer ${singer.id}`);
+  }
+}
+
 (async () => {
   await main();
 })().catch(e => {
diff --git a/samples/nodejs/sequelize/src/init.ts b/samples/nodejs/sequelize/src/init.ts
index 3027c2b4..7c8068e7 100644
--- a/samples/nodejs/sequelize/src/init.ts
+++ b/samples/nodejs/sequelize/src/init.ts
@@ -58,7 +58,7 @@ export async function createDataModel(sequelize: Sequelize) {
               title             varchar,
               "marketingBudget" numeric,
               "SingerId"        bigint,
-              "createdAt"       timestamptz,
+              "createdAt"       timestamptz default current_timestamp,
               "updatedAt"       timestamptz,
               constraint fk_albums_singers foreign key ("SingerId") references "Singers" (id)
             );
@@ -68,7 +68,7 @@ export async function createDataModel(sequelize: Sequelize) {
               "trackNumber" bigint not null,
               title         varchar not null,
               "sampleRate"  float8 not null,
-              "createdAt"   timestamptz,
+              "createdAt"   timestamptz default current_timestamp,
               "updatedAt"   timestamptz,
               primary key (id, "trackNumber")
             ) interleave in parent "Albums" on delete cascade;
@@ -78,7 +78,7 @@ export async function createDataModel(sequelize: Sequelize) {
               id          bigint not null primary key default nextval('venues_seq'),
               name        varchar not null,
               description varchar not null,
-              "createdAt" timestamptz,
+              "createdAt" timestamptz default current_timestamp,
               "updatedAt" timestamptz
             );
 
@@ -90,7 +90,7 @@ export async function createDataModel(sequelize: Sequelize) {
               name        varchar not null,
               "startTime" timestamptz not null,
               "endTime"   timestamptz not null,
-              "createdAt" timestamptz,
+              "createdAt" timestamptz default current_timestamp,
               "updatedAt" timestamptz,
               constraint fk_concerts_venues  foreign key ("VenueId")  references "Venues"  (id),
               constraint fk_concerts_singers foreign key ("SingerId") references "Singers" (id),
@@ -104,7 +104,7 @@ export async function createDataModel(sequelize: Sequelize) {
               "customerName" varchar not null,
               price          decimal not null,
               seats          text[],
-              "createdAt"    timestamptz,
+              "createdAt"    timestamptz default current_timestamp,
               "updatedAt"    timestamptz,
               constraint fk_ticket_sales_concerts foreign key ("ConcertId") references "Concerts" (id)
             );`,
diff --git a/samples/python/django/requirements.txt b/samples/python/django/requirements.txt
index ab065b49..1577f356 100644
--- a/samples/python/django/requirements.txt
+++ b/samples/python/django/requirements.txt
@@ -1,7 +1,7 @@
 psycopg-binary==3.2.1
 pytz==2024.1
-Django==5.1
+Django==5.1.1
 google==3.0.0
 google.auth==2.34.0
-testcontainers==4.8.0
+testcontainers==4.8.1
 requests==2.32.3
diff --git a/samples/python/psycopg3/requirements.txt b/samples/python/psycopg3/requirements.txt
index 658e4ab7..d4bf54b3 100644
--- a/samples/python/psycopg3/requirements.txt
+++ b/samples/python/psycopg3/requirements.txt
@@ -1,5 +1,5 @@
 psycopg[binary]~=3.2.1
-testcontainers~=4.8.0
+testcontainers~=4.8.1
 requests==2.32.3
 google~=3.0.0
 google.auth~=2.34.0
\ No newline at end of file
diff --git a/samples/python/sqlalchemy2-sample/model.py b/samples/python/sqlalchemy2-sample/model.py
index f49c490f..100decd4 100644
--- a/samples/python/sqlalchemy2-sample/model.py
+++ b/samples/python/sqlalchemy2-sample/model.py
@@ -31,7 +31,7 @@ def format_timestamp(timestamp: datetime) -> str:
  BaseMixin contains properties that are common to all models in this sample.
  
  The created_at and updated_at properties are automatically filled with the
- current client system time when a model is created or updated. 
+ current client system time when a model is created or updated.
 """
 class BaseMixin(object):
   __prepare_statements__ = None
diff --git a/samples/python/sqlalchemy2-sample/sample.py b/samples/python/sqlalchemy2-sample/sample.py
index 1685eb40..ed2ed2b4 100644
--- a/samples/python/sqlalchemy2-sample/sample.py
+++ b/samples/python/sqlalchemy2-sample/sample.py
@@ -12,6 +12,7 @@
  See the License for the specific language governing permissions and
  limitations under the License.
 """
+import uuid
 from random import random
 
 from connect import create_test_engine
@@ -20,9 +21,10 @@ from sqlalchemy import text, func, or_, Integer
 from model import Singer, Album, Track, Venue, Concert, TicketSale
 from util_random_names import random_first_name, random_last_name, \
   random_album_title, random_release_date, random_marketing_budget, \
-  random_cover_picture
+  random_cover_picture, random_boolean
 from uuid import uuid4
 from datetime import datetime, date, timezone
+from sqlalchemy.dialects.postgresql import insert
 
 # This is the default engine that is connected to PostgreSQL (PGAdapter).
 # This engine will by default use read/write transactions.
@@ -83,6 +85,14 @@ def run_sample():
       print("Album was found using a stale read, even though it has already "
             "been deleted.")
 
+  # Randomly either insert or update a new Singer record using an
+  # INSERT .. ON CONFLICT UPDATE .. statement.
+  if random_boolean():
+    singer_id = session.query(Singer).first().id
+  else:
+    singer_id = uuid.uuid4()
+  insert_or_update_singer(singer_id, random_first_name(), random_last_name())
+
   print()
   print("Finished running sample")
 
@@ -327,6 +337,39 @@ def update_singer(singer_id, first_name, last_name):
     session.commit()
 
 
+# Inserts-or-updates a singer record in the database. This generates an
+# INSERT ... ON CONFLICT (id) UPDATE ... statement.
+def insert_or_update_singer(singer_id, first_name, last_name):
+  with Session(engine) as session:
+    singer = Singer()
+    singer.id = singer_id
+    singer.first_name = first_name
+    singer.last_name = last_name
+    insert_stmt = insert(Singer).values(id=singer.id,
+                                        first_name=singer.first_name,
+                                        last_name=singer.last_name,
+                                        created_at=datetime.now(),
+                                        updated_at=datetime.now(),
+                                        version_id=1)
+    # Spanner requires that the ON CONFLICT clause specifies the conflict
+    # column(s), and that all columns that are included in the INSERT statement
+    # are also included in the UPDATE statement. The UPDATE statement may only
+    # contain 'my_col=excluded.my_col' assignments.
+    do_update_stmt = insert_stmt.on_conflict_do_update(
+      index_elements=['id'],
+      set_=dict(id=insert_stmt.excluded.id,
+                first_name=insert_stmt.excluded.first_name,
+                last_name=insert_stmt.excluded.last_name,
+                created_at=insert_stmt.excluded.created_at,
+                updated_at=insert_stmt.excluded.updated_at,
+                version_id=insert_stmt.excluded.version_id),
+    )
+    session.execute(do_update_stmt)
+    print("Inserted or updated singer {} with last name {}"
+          .format(singer.id, singer.last_name))
+    session.commit()
+
+
 # Loads the given album from the database and prints its properties, including
 # the tracks related to this album. The table `tracks` is interleaved in
 # `albums`. This is handled as a normal relationship in SQLAlchemy.
diff --git a/samples/python/sqlalchemy2-sample/util_random_names.py b/samples/python/sqlalchemy2-sample/util_random_names.py
index f8c65d39..f0a9b4bc 100644
--- a/samples/python/sqlalchemy2-sample/util_random_names.py
+++ b/samples/python/sqlalchemy2-sample/util_random_names.py
@@ -14,7 +14,7 @@
 """
 
 import secrets
-from random import seed, randrange, random
+from random import choice, seed, randrange, random
 from datetime import date
 
 seed()
@@ -24,6 +24,11 @@ seed()
  Helper functions for generating random names and titles.
 """
 
+
+def random_boolean():
+  return choice([True, False])
+
+
 def random_first_name():
   return first_names[randrange(len(first_names))]
 
diff --git a/samples/sidecar-proxy/Dockerfile b/samples/sidecar-proxy/Dockerfile
index bef9ee45..c7fe4fe7 100644
--- a/samples/sidecar-proxy/Dockerfile
+++ b/samples/sidecar-proxy/Dockerfile
@@ -12,7 +12,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-FROM golang:1.22.5 as builder
+FROM golang:1.23.0 as builder
 WORKDIR /app
 RUN go mod init example-app
 RUN go get github.com/jackc/pgx/v4
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/ConnectionHandler.java b/src/main/java/com/google/cloud/spanner/pgadapter/ConnectionHandler.java
index a4caf07e..918109a6 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/ConnectionHandler.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/ConnectionHandler.java
@@ -457,13 +457,14 @@ public class ConnectionHandler implements Runnable {
                   () ->
                       String.format(
                           "Exception while closing connection handler with ID %s", getName())));
+        } finally {
+          this.server.deregister(this);
+          logger.log(
+              Level.INFO,
+              Logging.format(
+                  "RunConnection",
+                  () -> String.format("Connection handler with ID %s closed", getName())));
         }
-        this.server.deregister(this);
-        logger.log(
-            Level.INFO,
-            Logging.format(
-                "RunConnection",
-                () -> String.format("Connection handler with ID %s closed", getName())));
       }
     }
     return result;
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/ProxyServer.java b/src/main/java/com/google/cloud/spanner/pgadapter/ProxyServer.java
index 9dbf2f1c..da4760aa 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/ProxyServer.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/ProxyServer.java
@@ -44,9 +44,12 @@ import java.util.Map;
 import java.util.Properties;
 import java.util.concurrent.ConcurrentLinkedQueue;
 import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
 import java.util.concurrent.ThreadFactory;
 import java.util.concurrent.TimeUnit;
 import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
 import java.util.logging.Level;
 import java.util.logging.Logger;
 import org.newsclub.net.unix.AFUNIXServerSocket;
@@ -63,7 +66,7 @@ public class ProxyServer extends AbstractApiService {
   private final OpenTelemetry openTelemetry;
   private final Metrics metrics;
   private final Properties properties;
-  private final List<ConnectionHandler> handlers = Collections.synchronizedList(new LinkedList<>());
+  private final List<ConnectionHandler> handlers = new LinkedList<>();
 
   /**
    * Latch that is closed when the TCP server has started. We need this to know the exact port that
@@ -88,6 +91,34 @@ public class ProxyServer extends AbstractApiService {
 
   private final ThreadFactory threadFactory;
 
+  /**
+   * Shutdown mode determines how the server handles any open connections when the server is being
+   * terminated. These modes correspond with the <a
+   * href="https://www.postgresql.org/docs/current/server-shutdown.html">shutdown modes of
+   * PostgreSQL</a>.
+   */
+  public enum ShutdownMode {
+    /**
+     * Stop accepting incoming connections and wait for all existing connections to finish before
+     * shutting down.
+     */
+    SMART,
+
+    /** Actively terminate all existing connections and shut down the server. */
+    FAST,
+
+    /**
+     * Actively terminate all existing connections and shut down the server. There is currently no
+     * difference between FAST and IMMEDIATE, but this could change in the future.
+     */
+    IMMEDIATE,
+  }
+
+  private final AtomicReference<ShutdownMode> shutdownMode = new AtomicReference<>();
+
+  private final AtomicReference<CountDownLatch> allHandlersTerminatedLatch =
+      new AtomicReference<>();
+
   /**
    * Instantiates the ProxyServer from CLI-gathered metadata.
    *
@@ -213,6 +244,7 @@ public class ProxyServer extends AbstractApiService {
 
   @Override
   protected void doStop() {
+    logger.log(Level.INFO, "Stopping server using shutdown mode {0}", this.shutdownMode.get());
     for (ServerSocket serverSocket : this.serverSockets) {
       try {
         logger.log(
@@ -227,22 +259,72 @@ public class ProxyServer extends AbstractApiService {
             () -> String.format("Closing server socket %s failed: %s", serverSocket, exception));
       }
     }
-    for (ConnectionHandler handler : getConnectionHandlers()) {
-      handler.terminate();
+    if (this.shutdownMode.get() == ShutdownMode.SMART) {
+      try {
+        waitForAllConnectionsToTerminate();
+      } catch (InterruptedException interruptedException) {
+        // The waiting thread will be interrupted if a new shutdown request with mode FAST or
+        // IMMEDIATE comes in. In that case, we actively terminate all connection handlers.
+        terminateAllConnectionHandlers();
+      }
+    } else {
+      terminateAllConnectionHandlers();
     }
     try {
-      SpannerPool.closeSpannerPool();
+      // Do a shoot-and-forget close of the underlying Spanner pool.
+      ExecutorService service = Executors.newSingleThreadExecutor(threadFactory);
+      service.submit(SpannerPool::closeSpannerPool);
+      service.shutdown();
+      if (shutdownMode.get() != ShutdownMode.IMMEDIATE) {
+        if (!service.awaitTermination(1L, TimeUnit.SECONDS)) {
+          service.shutdownNow();
+          logger.log(Level.INFO, "SpannerPool was not closed after waiting for 1 second");
+        }
+      }
     } catch (Throwable ignore) {
     }
+    if (openTelemetry instanceof Closeable) {
+      try {
+        ((Closeable) openTelemetry).close();
+      } catch (IOException ioException) {
+        logger.log(Level.WARNING, "Failed to close OpenTelemetry", ioException);
+      }
+    }
     notifyStopped();
   }
 
-  /** Safely stops the server (iff started), closing specific socket and cleaning up. */
+  private void terminateAllConnectionHandlers() {
+    logger.log(Level.INFO, "Terminating {0} connections", getNumberOfConnections());
+    for (ConnectionHandler handler : getConnectionHandlers()) {
+      handler.terminate();
+    }
+  }
+
+  private void waitForAllConnectionsToTerminate() throws InterruptedException {
+    logger.log(Level.INFO, "Waiting for {0} connections to terminate", getNumberOfConnections());
+    createConnectionHandlersTerminatedLatch();
+    this.allHandlersTerminatedLatch.get().await();
+  }
+
+  /**
+   * Stops the server using the FAST shutdown mode, terminating all existing connections and
+   * cleaning up.
+   */
   public void stopServer() {
+    stopServer(ShutdownMode.FAST);
+  }
+
+  /** Stops the server using the specified shutdown mode. */
+  public void stopServer(ShutdownMode shutdownMode) {
+    setShutdownMode(shutdownMode);
     stopAsync();
     awaitTerminated();
   }
 
+  void setShutdownMode(ShutdownMode shutdownMode) {
+    this.shutdownMode.set(shutdownMode);
+  }
+
   /**
    * Thread logic: opens the TCP listening socket and instantiates the connection handler.
    *
@@ -313,18 +395,15 @@ public class ProxyServer extends AbstractApiService {
         createConnectionHandler(serverSocket.accept());
       }
     } catch (SocketException e) {
-      // This is a normal exception, as this will occur when Server#stopServer() is called.
+      // This is a normal and expected exception when the server is shutting down.
       logger.log(
-          Level.INFO,
+          shutdownMode.get() == null ? Level.WARNING : Level.FINEST,
           () ->
               String.format(
                   "Socket exception on socket %s: %s. This is normal when the server is stopped.",
                   serverSocket, e));
     } finally {
       logger.log(Level.INFO, () -> String.format("Socket %s stopped", serverSocket));
-      if (openTelemetry instanceof Closeable) {
-        ((Closeable) openTelemetry).close();
-      }
       stoppedLatch.countDown();
     }
   }
@@ -350,7 +429,15 @@ public class ProxyServer extends AbstractApiService {
 
   /** Returns an immutable copy of the current connection handlers at this server. */
   ImmutableList<ConnectionHandler> getConnectionHandlers() {
-    return ImmutableList.copyOf(this.handlers);
+    synchronized (this.handlers) {
+      return ImmutableList.copyOf(this.handlers);
+    }
+  }
+
+  private void createConnectionHandlersTerminatedLatch() {
+    synchronized (this.handlers) {
+      this.allHandlersTerminatedLatch.set(new CountDownLatch(this.handlers.size()));
+    }
   }
 
   /**
@@ -359,7 +446,9 @@ public class ProxyServer extends AbstractApiService {
    * @param handler The handler currently in use.
    */
   private void register(ConnectionHandler handler) {
-    this.handlers.add(handler);
+    synchronized (this.handlers) {
+      this.handlers.add(handler);
+    }
   }
 
   /**
@@ -368,7 +457,12 @@ public class ProxyServer extends AbstractApiService {
    * @param handler The handler to revoke.
    */
   void deregister(ConnectionHandler handler) {
-    this.handlers.remove(handler);
+    synchronized (this.handlers) {
+      this.handlers.remove(handler);
+      if (this.allHandlersTerminatedLatch.get() != null) {
+        this.allHandlersTerminatedLatch.get().countDown();
+      }
+    }
   }
 
   public OptionsMetadata getOptions() {
@@ -396,7 +490,9 @@ public class ProxyServer extends AbstractApiService {
 
   /** @return the current number of connections. */
   public int getNumberOfConnections() {
-    return this.handlers.size();
+    synchronized (this.handlers) {
+      return this.handlers.size();
+    }
   }
 
   /** @return the local TCP port that this server is using. */
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/Server.java b/src/main/java/com/google/cloud/spanner/pgadapter/Server.java
index edf41253..958a9a3b 100644
--- a/src/main/java/com/google/cloud/spanner/pgadapter/Server.java
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/Server.java
@@ -21,6 +21,7 @@ import com.google.cloud.opentelemetry.metric.GoogleCloudMetricExporter;
 import com.google.cloud.opentelemetry.metric.MetricConfiguration;
 import com.google.cloud.opentelemetry.trace.TraceConfiguration;
 import com.google.cloud.opentelemetry.trace.TraceExporter;
+import com.google.cloud.spanner.pgadapter.ProxyServer.ShutdownMode;
 import com.google.cloud.spanner.pgadapter.logging.DefaultLogConfiguration;
 import com.google.cloud.spanner.pgadapter.metadata.OptionsMetadata;
 import com.google.common.collect.ImmutableMap;
@@ -40,15 +41,28 @@ import java.io.File;
 import java.io.FileReader;
 import java.io.IOException;
 import java.io.PrintStream;
+import java.lang.invoke.CallSite;
+import java.lang.invoke.LambdaMetafactory;
+import java.lang.invoke.MethodHandle;
+import java.lang.invoke.MethodHandles;
+import java.lang.invoke.MethodType;
+import java.lang.reflect.Constructor;
+import java.lang.reflect.Method;
 import java.time.Duration;
 import java.util.Locale;
 import java.util.Objects;
 import java.util.concurrent.ThreadLocalRandom;
+import java.util.logging.Level;
+import java.util.logging.Logger;
 import org.apache.maven.model.Model;
 import org.apache.maven.model.io.xpp3.MavenXpp3Reader;
+import org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement;
 
 /** Effectively this is the main class */
 public class Server {
+  private static final Logger logger = Logger.getLogger(Server.class.getName());
+
+  private static ShutdownHandler shutdownHandler;
 
   /**
    * Main method for running a Spanner PostgreSQL Adapter {@link Server} as a stand-alone
@@ -59,13 +73,142 @@ public class Server {
       DefaultLogConfiguration.configureLogging(args);
       OptionsMetadata optionsMetadata = extractMetadata(args, System.out);
       OpenTelemetry openTelemetry = setupOpenTelemetry(optionsMetadata);
-      ProxyServer server = new ProxyServer(optionsMetadata, openTelemetry);
-      server.startServer();
+      ProxyServer proxyServer = new ProxyServer(optionsMetadata, openTelemetry);
+      proxyServer.startServer();
+
+      // Create a shutdown handler and register signal handlers for the signals that should
+      // terminate the server.
+      Server.shutdownHandler = ShutdownHandler.createForServer(proxyServer);
+      registerSignalHandlers();
     } catch (Exception e) {
       printError(e, System.err, System.out);
     }
   }
 
+  /**
+   * Registers signal handlers for TERM, INT, and QUIT. This method uses reflection and fails
+   * gracefully if signal handling is not available on this JVM.
+   */
+  static void registerSignalHandlers() {
+    Class<?> signalClass = getSignalClass();
+    Class<?> signalHandlerClass = getSignalHandlerClass();
+    if (signalClass == null || signalHandlerClass == null) {
+      return;
+    }
+    try {
+      Method handleMethod =
+          signalClass.getDeclaredMethod("handle", signalClass, signalHandlerClass);
+      Constructor<?> signalConstructor = signalClass.getConstructor(String.class);
+
+      Object termSignal = signalConstructor.newInstance("TERM");
+      Object intSignal = signalConstructor.newInstance("INT");
+      Object quitSignal = signalConstructor.newInstance("QUIT");
+
+      Object termHandler = createSignalHandler(signalClass, signalHandlerClass, "handleTerm");
+      Object intHandler = createSignalHandler(signalClass, signalHandlerClass, "handleInt");
+      Object quitHandler = createSignalHandler(signalClass, signalHandlerClass, "handleQuit");
+
+      // Register the actual signal handlers. This will fail if the JVM has already registered a
+      // signal handler for the given signal. Normally, TERM and INT can be registered. QUIT is
+      // normally registered by the JVM.
+      try {
+        handleMethod.invoke(null, termSignal, termHandler);
+      } catch (Throwable throwable) {
+        logger.log(Level.WARNING, "Failed to register signal handler for TERM", throwable);
+      }
+      try {
+        handleMethod.invoke(null, intSignal, intHandler);
+      } catch (Throwable throwable) {
+        logger.log(Level.WARNING, "Failed to register signal handler for INT", throwable);
+      }
+      try {
+        handleMethod.invoke(null, quitSignal, quitHandler);
+      } catch (Throwable throwable) {
+        // Log this at FINE level, as QUIT is normally already registered by the JVM. This means
+        // that registering QUIT will fail on most JVMs. This means that QUIT signals will still
+        // lead to the server stopping quickly, just that it is handled directly by the JVM instead
+        // of PGAdapter.
+        logger.log(Level.FINE, "Failed to register signal handler for QUIT", throwable);
+      }
+    } catch (Throwable exception) {
+      logger.log(Level.WARNING, "Failed to register signal handlers", exception);
+    }
+  }
+
+  private static Class<?> getSignalClass() {
+    try {
+      return Class.forName("sun.misc.Signal");
+    } catch (ClassNotFoundException exception) {
+      logger.log(
+          Level.INFO,
+          "Cannot register shutdown signal handlers as sun.misc.Signal is not available on this JVM");
+      return null;
+    }
+  }
+
+  private static Class<?> getSignalHandlerClass() {
+    try {
+      return Class.forName("sun.misc.SignalHandler");
+    } catch (ClassNotFoundException exception) {
+      logger.log(
+          Level.INFO,
+          "Cannot register shutdown signal handlers as sun.misc.SignalHandler is not available on this JVM");
+      return null;
+    }
+  }
+
+  /**
+   * This method dynamically creates the signal handler lambdas that call the handleTerm, handleInt,
+   * and handleQuit methods. We need to do this through reflection, because we cannot use the
+   * sun.misc.SignalHandler interface directly to create the lambda expressions.
+   */
+  @IgnoreJRERequirement
+  private static Object createSignalHandler(
+      Class<?> signalClass, Class<?> signalHandlerClass, String handleMethodName) throws Throwable {
+    MethodHandles.Lookup caller = MethodHandles.lookup();
+    MethodType signalHandlerHandleMethodType = MethodType.methodType(void.class, signalClass);
+    MethodType invokedType = MethodType.methodType(signalHandlerClass);
+
+    CallSite handleTermSite =
+        LambdaMetafactory.metafactory(
+            caller,
+            "handle",
+            invokedType,
+            signalHandlerHandleMethodType,
+            caller.findStatic(
+                Server.class, handleMethodName, MethodType.methodType(void.class, Object.class)),
+            signalHandlerHandleMethodType);
+    MethodHandle factory = handleTermSite.getTarget();
+    return factory.invoke();
+  }
+
+  /** This method is called by the signal handler that is registered for TERM. */
+  static void handleTerm(Object ignoredSignal) {
+    if (Server.shutdownHandler == null) {
+      return;
+    }
+    logger.log(Level.INFO, "Server received TERM");
+    Server.shutdownHandler.shutdown(ShutdownMode.SMART);
+  }
+
+  /** This method is called by the signal handler that is registered for INT. */
+  static void handleInt(Object ignoredSignal) {
+    if (Server.shutdownHandler == null) {
+      return;
+    }
+    logger.log(Level.INFO, "Server received INT");
+    Server.shutdownHandler.shutdown(ShutdownMode.FAST);
+  }
+
+  /** This method is called by the signal handler that is registered for QUIT. */
+  static void handleQuit(Object ignoredSignal) {
+    if (Server.shutdownHandler == null) {
+      return;
+    }
+    logger.log(Level.INFO, "Server received QUIT");
+    Server.shutdownHandler.shutdown(ShutdownMode.IMMEDIATE);
+  }
+
   /** Creates an {@link OpenTelemetry} object from the given options. */
   static OpenTelemetry setupOpenTelemetry(OptionsMetadata optionsMetadata) {
     if (!optionsMetadata.isEnableOpenTelemetry()
diff --git a/src/main/java/com/google/cloud/spanner/pgadapter/ShutdownHandler.java b/src/main/java/com/google/cloud/spanner/pgadapter/ShutdownHandler.java
new file mode 100644
index 00000000..23c67d8b
--- /dev/null
+++ b/src/main/java/com/google/cloud/spanner/pgadapter/ShutdownHandler.java
@@ -0,0 +1,61 @@
+// Copyright 2024 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.cloud.spanner.pgadapter;
+
+import com.google.cloud.spanner.pgadapter.ProxyServer.ShutdownMode;
+import com.google.common.base.Preconditions;
+import java.util.concurrent.atomic.AtomicReference;
+import javax.annotation.Nonnull;
+
+/**
+ * Handles the orderly shutdown of a {@link ProxyServer}. A {@link ShutdownHandler} can only be used
+ * once.
+ */
+public class ShutdownHandler {
+  private final ProxyServer proxyServer;
+  private final Thread shutdownThread;
+  private final AtomicReference<ShutdownMode> shutdownMode = new AtomicReference<>();
+
+  static ShutdownHandler createForServer(@Nonnull ProxyServer proxyServer) {
+    return new ShutdownHandler(proxyServer);
+  }
+
+  /** Constructor for a {@link ShutdownHandler} for a given {@link ProxyServer}. */
+  private ShutdownHandler(@Nonnull ProxyServer proxyServer) {
+    this.proxyServer = Preconditions.checkNotNull(proxyServer);
+    this.shutdownThread =
+        new Thread("server-shutdown-handler") {
+          @Override
+          public void run() {
+            ShutdownHandler.this.proxyServer.stopServer(ShutdownHandler.this.shutdownMode.get());
+          }
+        };
+  }
+
+  /** Shuts down the proxy server using the given {@link ShutdownMode}. */
+  public synchronized void shutdown(@Nonnull ShutdownMode shutdownMode) {
+    if (this.shutdownMode.get() != null) {
+      if (this.shutdownMode.get() == ShutdownMode.SMART
+          && (shutdownMode == ShutdownMode.FAST || shutdownMode == ShutdownMode.IMMEDIATE)) {
+        // Upgrade shutdown mode and interrupt the shutdown thread.
+        this.proxyServer.setShutdownMode(shutdownMode);
+        this.shutdownThread.interrupt();
+      }
+      return;
+    }
+    this.shutdownMode.set(Preconditions.checkNotNull(shutdownMode));
+    this.shutdownThread.start();
+  }
+}


Test patch:
diff --git a/samples/snippets/dotnet-snippets/sample-tests/sample-tests.csproj b/samples/snippets/dotnet-snippets/sample-tests/sample-tests.csproj
index 7e50b25d..89ae04c4 100644
--- a/samples/snippets/dotnet-snippets/sample-tests/sample-tests.csproj
+++ b/samples/snippets/dotnet-snippets/sample-tests/sample-tests.csproj
@@ -16,7 +16,7 @@
         <PackageReference Include="NUnit" Version="4.2.2" />
         <PackageReference Include="NUnit.Analyzers" Version="4.3.0"/>
         <PackageReference Include="NUnit3TestAdapter" Version="4.6.0"/>
-        <PackageReference Include="Testcontainers" Version="3.9.0" />
+        <PackageReference Include="Testcontainers" Version="3.10.0" />
     </ItemGroup>
 
     <ItemGroup>
diff --git a/src/test/java/com/google/cloud/spanner/pgadapter/ShutdownModeMockServerTest.java b/src/test/java/com/google/cloud/spanner/pgadapter/ShutdownModeMockServerTest.java
new file mode 100644
index 00000000..e3b58109
--- /dev/null
+++ b/src/test/java/com/google/cloud/spanner/pgadapter/ShutdownModeMockServerTest.java
@@ -0,0 +1,229 @@
+// Copyright 2024 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//      http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.cloud.spanner.pgadapter;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertThrows;
+import static org.junit.Assert.assertTrue;
+
+import com.google.api.core.ApiService.State;
+import com.google.cloud.NoCredentials;
+import com.google.cloud.spanner.pgadapter.ProxyServer.ShutdownMode;
+import com.google.cloud.spanner.pgadapter.metadata.TestOptionsMetadataBuilder;
+import com.google.common.base.Stopwatch;
+import io.opentelemetry.api.OpenTelemetry;
+import java.io.IOException;
+import java.net.Socket;
+import java.net.SocketException;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.concurrent.TimeUnit;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+@RunWith(JUnit4.class)
+public class ShutdownModeMockServerTest extends AbstractMockServerTest {
+  private ProxyServer proxyServer;
+
+  @Before
+  public void createProxyServer() {
+    TestOptionsMetadataBuilder builder = new TestOptionsMetadataBuilder();
+    builder.setProject("p").setInstance("i").setDatabase("d");
+    builder
+        .enableDebugMode()
+        .setUsePlainText()
+        .setEndpoint(String.format("localhost:%d", spannerServer.getPort()))
+        .setCredentials(NoCredentials.getInstance());
+    proxyServer = new ProxyServer(builder.build(), OpenTelemetry.noop());
+    proxyServer.startServer();
+  }
+
+  @After
+  public void stopProxyServer() {
+    proxyServer.stopServer();
+  }
+
+  private String createUrl() {
+    return String.format("jdbc:postgresql://localhost:%d/d", proxyServer.getLocalPort());
+  }
+
+  @Test
+  public void testSmartShutdown() throws SQLException {
+    String sql = "SELECT 1";
+
+    ShutdownHandler shutdownHandler = ShutdownHandler.createForServer(proxyServer);
+    // Verify that the server is running.
+    assertEquals(State.RUNNING, proxyServer.state());
+
+    try (Connection connection = DriverManager.getConnection(createUrl())) {
+      // Verify that the connection is valid.
+      try (ResultSet resultSet = connection.createStatement().executeQuery(sql)) {
+        assertTrue(resultSet.next());
+        assertEquals(1L, resultSet.getLong(1));
+        assertFalse(resultSet.next());
+      }
+      // Initiate a smart shutdown.
+      shutdownHandler.shutdown(ShutdownMode.SMART);
+      // Wait until the server port is no longer open.
+      Stopwatch stopwatch = Stopwatch.createStarted();
+      while (isPortListening(proxyServer.getLocalPort())
+          && stopwatch.elapsed(TimeUnit.MILLISECONDS) < 2000) {
+        Thread.yield();
+      }
+      assertEquals(State.STOPPING, proxyServer.state());
+      // Verify that we can still use the existing connection.
+      try (ResultSet resultSet = connection.createStatement().executeQuery(sql)) {
+        assertTrue(resultSet.next());
+        assertEquals(1L, resultSet.getLong(1));
+        assertFalse(resultSet.next());
+      }
+      // Verify that we cannot open a new connection.
+      SQLException exception =
+          assertThrows(SQLException.class, () -> DriverManager.getConnection(createUrl()));
+      assertEquals(
+          String.format(
+              "Connection to localhost:%d refused. Check that the hostname and port are correct and that the postmaster is accepting TCP/IP connections.",
+              proxyServer.getLocalPort()),
+          exception.getMessage());
+      // Verify that the original connection is still valid.
+      try (ResultSet resultSet = connection.createStatement().executeQuery(sql)) {
+        assertTrue(resultSet.next());
+        assertEquals(1L, resultSet.getLong(1));
+        assertFalse(resultSet.next());
+      }
+      // Verify that the server is still stopping, but not yet terminated.
+      assertEquals(State.STOPPING, proxyServer.state());
+    }
+    Stopwatch stopwatch = Stopwatch.createStarted();
+    while (proxyServer.state() == State.STOPPING
+        && stopwatch.elapsed(TimeUnit.MILLISECONDS) < 2000) {
+      Thread.yield();
+    }
+    // Verify that closing the last connection terminates the server.
+    assertEquals(State.TERMINATED, proxyServer.state());
+  }
+
+  @Test
+  public void testFastShutdown() throws SQLException {
+    String sql = "SELECT 1";
+
+    ShutdownHandler shutdownHandler = ShutdownHandler.createForServer(proxyServer);
+    // Verify that the server is running.
+    assertEquals(State.RUNNING, proxyServer.state());
+
+    try (Connection connection = DriverManager.getConnection(createUrl())) {
+      // Verify that the connection is valid.
+      try (ResultSet resultSet = connection.createStatement().executeQuery(sql)) {
+        assertTrue(resultSet.next());
+        assertEquals(1L, resultSet.getLong(1));
+        assertFalse(resultSet.next());
+      }
+      // Initiate a fast shutdown.
+      shutdownHandler.shutdown(ShutdownMode.FAST);
+      // Verify that the server stops.
+      Stopwatch stopwatch = Stopwatch.createStarted();
+      while (proxyServer.state() != State.TERMINATED
+          && stopwatch.elapsed(TimeUnit.MILLISECONDS) < 2000) {
+        Thread.yield();
+      }
+      assertEquals(State.TERMINATED, proxyServer.state());
+
+      // Verify that existing connection has been invalidated.
+      SQLException exception =
+          assertThrows(SQLException.class, () -> connection.createStatement().executeQuery(sql));
+      assertEquals("An I/O error occurred while sending to the backend.", exception.getMessage());
+
+      // Verify that we cannot open a new connection.
+      exception = assertThrows(SQLException.class, () -> DriverManager.getConnection(createUrl()));
+      assertEquals(
+          String.format(
+              "Connection to localhost:%d refused. Check that the hostname and port are correct and that the postmaster is accepting TCP/IP connections.",
+              proxyServer.getLocalPort()),
+          exception.getMessage());
+    }
+  }
+
+  @Test
+  public void testSmartShutdownFollowedByFastShutdown() throws SQLException {
+    String sql = "SELECT 1";
+
+    ShutdownHandler shutdownHandler = ShutdownHandler.createForServer(proxyServer);
+    // Verify that the server is running.
+    assertEquals(State.RUNNING, proxyServer.state());
+
+    try (Connection connection = DriverManager.getConnection(createUrl())) {
+      // Verify that the connection is valid.
+      try (ResultSet resultSet = connection.createStatement().executeQuery(sql)) {
+        assertTrue(resultSet.next());
+        assertEquals(1L, resultSet.getLong(1));
+        assertFalse(resultSet.next());
+      }
+      // Initiate a smart shutdown.
+      shutdownHandler.shutdown(ShutdownMode.SMART);
+      // Wait until the server port is no longer open.
+      Stopwatch stopwatch = Stopwatch.createStarted();
+      while (isPortListening(proxyServer.getLocalPort())
+          && stopwatch.elapsed(TimeUnit.MILLISECONDS) < 2000) {
+        Thread.yield();
+      }
+      // Verify that we can still use the existing connection.
+      try (ResultSet resultSet = connection.createStatement().executeQuery(sql)) {
+        assertTrue(resultSet.next());
+        assertEquals(1L, resultSet.getLong(1));
+        assertFalse(resultSet.next());
+      }
+      // Verify that we cannot open a new connection.
+      SQLException exception =
+          assertThrows(SQLException.class, () -> DriverManager.getConnection(createUrl()));
+      assertEquals(
+          String.format(
+              "Connection to localhost:%d refused. Check that the hostname and port are correct and that the postmaster is accepting TCP/IP connections.",
+              proxyServer.getLocalPort()),
+          exception.getMessage());
+      // Verify that the server is stopping, but not yet terminated.
+      assertEquals(State.STOPPING, proxyServer.state());
+
+      // Now force the server to stop by initiating a fast shutdown.
+      shutdownHandler.shutdown(ShutdownMode.FAST);
+      stopwatch = Stopwatch.createStarted();
+      while (proxyServer.state() != State.TERMINATED
+          && stopwatch.elapsed(TimeUnit.MILLISECONDS) < 2000) {
+        Thread.yield();
+      }
+      assertEquals(State.TERMINATED, proxyServer.state());
+
+      // Verify that existing connection has been invalidated.
+      exception =
+          assertThrows(SQLException.class, () -> connection.createStatement().executeQuery(sql));
+      assertEquals("An I/O error occurred while sending to the backend.", exception.getMessage());
+    }
+  }
+
+  boolean isPortListening(int port) {
+    try (Socket ignored = new Socket("localhost", port)) {
+      return true;
+    } catch (SocketException e) {
+      return false;
+    } catch (IOException e) {
+      throw new RuntimeException(e);
+    }
+  }
+}