Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Concurrent requests adding the same session attribute result in duplicate key constraint violation #1213

Closed
ovanekem opened this issue Sep 26, 2018 · 52 comments · Fixed by #1726
Assignees
Labels
in: jdbc type: enhancement A general enhancement
Milestone

Comments

@ovanekem
Copy link

We are using Spring session 2.0.5.RELEASE. We are using JDBC session persistence.
We have migrated those applications that were previously using container in memory session (Wildfly/Undertow). The code has stayed as-is.

We have html pages that fire up multiple calls in AJAX simultaneously to a controller.
In the controller we have code that set an attribute into the session.

The issue is that multiple threads of the application server are busy in parallel treating those AJAX calls, the first thread will insert the new attribute in the database. The other threads are then supposed to call an update of the attribute in the database because the attribute already exists. But the issue is that the other threads have not detected (yet) that the attribute already exists and try to do an insert as well resulting in a duplicate key exception.

I must say that as a workaround we have patched the application to do a sort of initialisation AJAX call at page load and this initialisation just set the attribute with a blank value, this allows the attribute row to be created and when further calls are being made in parallel, the JDBC implementation of Spring session sees that only updates are needed.

However I wanted to report this in order to support those use cases natively (without workarounds) using JDBC persistence.

@rwinch rwinch added the status: waiting-for-triage An issue we've not yet triaged label Oct 3, 2018
@vpavic vpavic removed the status: waiting-for-triage An issue we've not yet triaged label Oct 4, 2018
@vpavic
Copy link
Contributor

vpavic commented Oct 4, 2018

Thanks for the report @ovanekem. This is a duplicate of #1031 which has been resolved in 2.1.0.M2 and backported to 2.0.6.RELEASE via #1151.

@vpavic
Copy link
Contributor

vpavic commented Oct 4, 2018

Duplicate of #1031

@vpavic vpavic marked this as a duplicate of #1031 Oct 4, 2018
@vpavic vpavic closed this as completed Oct 4, 2018
@vpavic vpavic added the status: duplicate A duplicate of another issue label Oct 4, 2018
@vpavic vpavic removed the status: duplicate A duplicate of another issue label Oct 6, 2018
@vpavic
Copy link
Contributor

vpavic commented Oct 6, 2018

I think I incorrectly labeled this as duplicate of #1031 while in fact it is a different problem - apologies for that @ovanekem.

Can you provide a stack trace, or even better, a minimal sample to reproduce the problem? Also can you take a look at #1216 and see whether that appears to be the same problem?

@vpavic vpavic reopened this Oct 6, 2018
@vpavic vpavic added the status: waiting-for-feedback We need additional information before we can continue label Oct 6, 2018
@ovanekem
Copy link
Author

@vpavic I'll try to create a sample project this weekend that reproduces the issue.

@ovanekem
Copy link
Author

@vpavic I now have created some test cases that reproduce the issue. Attached is a ZIP file that contains the project. It is a Spring MVC project running on Wildfly and using Spring session with JDBC persistence.
There are prepared Docker containers so that you do not have to install Wildfly,... One container for wildfly (and the application) and one container with postgresql configured for the session persistence.
You can start the docker-compose.yml and it should start seamlessly.
When started, go to http://localhost/test/main. You will see an HTML page with 4 buttons: initialize (the HTTP session), call AJAX 20 times asynchronously, call AJAX 20 times synchronously, invalidate (the session).
You can try by clicking on initialize and it will call a controller that just creates the web session (request.getSession(true)).
Then when you try calling the AJAX controller 20 times ASYNCHRONOUSLY you will normally get a few HTTP 500. On the console of the wildly container you will clearly see the exception:

test-app | at java.lang.Thread.run(Thread.java:748)
test-app | Caused by: org.postgresql.util.PSQLException: ERROR: duplicate key value violates unique constraint "spring_session_attributes_pk"
test-app | Detail: Key (session_primary_id, attribute_name)=(2d716b1a-94a6-4041-ba40-a3428204f115, numberOfCalls) already exists.
test-app | at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2310)
test-app | at org.postgresql.core.v3.QueryExecutorImpl.processResults(QueryExecutorImpl.java:2023)
test-app | at org.postgresql.core.v3.QueryExecutorImpl.execute(QueryExecutorImpl.java:217)
test-app | at org.postgresql.jdbc.PgStatement.execute(PgStatement.java:421)
test-app | at org.postgresql.jdbc.PgPreparedStatement.executeWithFlags(PgPreparedStatement.java:166)

You can also see that doing the same REST calls without the asynchronous flag, we do not have an issue.
Hope you can run this sample and that it helps you in the investigation.

Regards

Olivier
test-spring-session-ajax.zip

@vpavic vpavic removed the status: waiting-for-feedback We need additional information before we can continue label Oct 30, 2018
@vpavic
Copy link
Contributor

vpavic commented Nov 1, 2018

Thanks for following up and providing the sample @ovanekem - I now have the understanding of what's going on.

After giving this issue some thought recently, I'm afraid this is a difficult problem to address without making some compromises in other places.

We've done quite a few optimizations to JdbcOperationsSessionRepository, largely thanks to the feedback from our community, however relational database as the underlying session store will always be limited compared to stores like Redis or Hazelcast. Part of this is due to the fact that with relational database we use different command (INSERT vs UPDATE) depending whether the attribute is already present or not. In a highly concurrent system, race conditions are bound to happen, and this is where the limitation of case when we're saving the attribute that's not already present (i.e. the INSERT scenario) comes to play.

Typically, you can address a problem like this in two ways:

  • locking mechanism - this isn't a viable option for Spring Session, as we'd need to use distributed lock which would have a degrading effect on performance
  • introduce error handling mechanism - this could be acceptable option, but it raises other concerns:
    • in order to do so, we would have to drop our JDBC batching optimization for INSERTs
    • when we catch the DuplicateKeyException, do we have enough knowledge to determine what to do:
      • log the exception and carry on - this means we assume the other request that managed to do INSERT has the actual attribute value
      • transform INSERT statement to UPDATE - this means we assume our attribute value is actual and we overwrite the other request

Do you have any thoughts on this @rwinch?

It's also worth noting that in some cases these problems can be address in application logic - see #1216 reported by @bonhamcm and in particular this comment.

@vpavic vpavic added the for: stack-overflow A question that's better suited to stackoverflow.com label Nov 1, 2018
@bonhamcm
Copy link

bonhamcm commented Nov 1, 2018

Could you add an option to perform a merge or upsert depending on the database? You can use INSERT INTO ... ON CONFLICT UPDATE in PostgresSQL 9.5+:

https://www.postgresql.org/docs/9.5/static/sql-insert.html

@vpavic
Copy link
Contributor

vpavic commented Nov 1, 2018

We are trying to stick with standard SQL for obvious reasons. There is JdbcOperationsSessionRepository#setCreateSessionAttributeQuery (and equivalents for other cases) available to provide custom SQL - that could indeed be used as a workaround.

@pminearo
Copy link

We just upgraded and are getting the error below. We have the following from Spring:

Spring Core: 5.0.5.RELEASE
Spring Security: 5.1.1.RELEASE
Spring Session: 2.1.2.RELEASE

[o.s.j.s.SQLErrorCodesFactory] [http-nio-127.0.0.1-11450-exec-9]: SQLErrorCodes loaded: [DB2, Derby, H2, HDB, HSQL, Informix, MS-SQL, MySQL, Oracle, PostgreSQL, Sybase]
[o.a.c.c.C.[.[.[.[default]] [http-nio-127.0.0.1-11450-exec-9]: Servlet.service() for servlet [default] in context with path [] threw exception
org.springframework.dao.DuplicateKeyException: PreparedStatementCallback; SQL [INSERT INTO SPRING_SESSION_ATTRIBUTES(SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES) SELECT PRIMARY_ID, ?, ? FROM SPRING_SESSION WHERE SESSION_ID = ?ORA-00001: unique constraint (SPRING_SESSION_ATTRIBUTES_PK) violated
; nested exception is java.sql.SQLIntegrityConstraintViolationException: ORA-00001: unique constraint (SPRING_SESSION_ATTRIBUTES_PK) violated
at org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator.doTranslate(SQLErrorCodeSQLExceptionTranslator.java:242)
at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:72)
at org.springframework.jdbc.core.JdbcTemplate.translateException(JdbcTemplate.java:1402)
at org.springframework.jdbc.core.JdbcTemplate.execute(JdbcTemplate.java:620)
at org.springframework.jdbc.core.JdbcTemplate.update(JdbcTemplate.java:850)
at org.springframework.jdbc.core.JdbcTemplate.update(JdbcTemplate.java:905)
at org.springframework.session.jdbc.JdbcOperationsSessionRepository.insertSessionAttributes(JdbcOperationsSessionRepository.java:547)
at org.springframework.session.jdbc.JdbcOperationsSessionRepository.access$300(JdbcOperationsSessionRepository.java:135)
at org.springframework.session.jdbc.JdbcOperationsSessionRepository$3.doInTransactionWithoutResult(JdbcOperationsSessionRepository.java:442)
at org.springframework.transaction.support.TransactionCallbackWithoutResult.doInTransaction(TransactionCallbackWithoutResult.java:36)
at org.springframework.transaction.support.TransactionTemplate.execute(TransactionTemplate.java:140)
at org.springframework.session.jdbc.JdbcOperationsSessionRepository.save(JdbcOperationsSessionRepository.java:421)
at org.springframework.session.jdbc.JdbcOperationsSessionRepository.save(JdbcOperationsSessionRepository.java:135)
at org.springframework.session.web.http.SessionRepositoryFilter$SessionRepositoryRequestWrapper.commitSession(SessionRepositoryFilter.java:240)
at org.springframework.session.web.http.SessionRepositoryFilter$SessionRepositoryRequestWrapper.access$100(SessionRepositoryFilter.java:201)
at org.springframework.session.web.http.SessionRepositoryFilter.doFilterInternal(SessionRepositoryFilter.java:154)
at org.springframework.session.web.http.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:81)
at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:357)
at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:270)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:240)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:207)
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:212)
at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:94)
at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:496)
at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:141)
at psiprobe.Tomcat80AgentValve.invoke(Tomcat80AgentValve.java:45)
at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:79)
at org.apache.catalina.valves.AbstractAccessLogValve.invoke(AbstractAccessLogValve.java:620)
at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:88)
at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:502)
at org.apache.coyote.http11.AbstractHttp11Processor.process(AbstractHttp11Processor.java:1156)
at org.apache.coyote.AbstractProtocol$AbstractConnectionHandler.process(AbstractProtocol.java:684)
at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1539)
at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.run(NioEndpoint.java:1495)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
at java.lang.Thread.run(Thread.java:748)
Caused by: java.sql.SQLIntegrityConstraintViolationException: ORA-00001: unique constraint (SPRING_SESSION_ATTRIBUTES_PK) violated
at oracle.jdbc.driver.T4CTTIoer11.processError(T4CTTIoer11.java:494)
at oracle.jdbc.driver.T4CTTIoer11.processError(T4CTTIoer11.java:446)
at oracle.jdbc.driver.T4C8Oall.processError(T4C8Oall.java:1054)
at oracle.jdbc.driver.T4CTTIfun.receive(T4CTTIfun.java:623)
at oracle.jdbc.driver.T4CTTIfun.doRPC(T4CTTIfun.java:252)
at oracle.jdbc.driver.T4C8Oall.doOALL(T4C8Oall.java:612)
at oracle.jdbc.driver.T4CPreparedStatement.doOall8(T4CPreparedStatement.java:226)
at oracle.jdbc.driver.T4CPreparedStatement.doOall8(T4CPreparedStatement.java:59)
at oracle.jdbc.driver.T4CPreparedStatement.executeForRows(T4CPreparedStatement.java:910)
at oracle.jdbc.driver.OracleStatement.doExecuteWithTimeout(OracleStatement.java:1119)
at oracle.jdbc.driver.OraclePreparedStatement.executeInternal(OraclePreparedStatement.java:3780)
at oracle.jdbc.driver.T4CPreparedStatement.executeInternal(T4CPreparedStatement.java:1343)
at oracle.jdbc.driver.OraclePreparedStatement.executeLargeUpdate(OraclePreparedStatement.java:3865)
at oracle.jdbc.driver.OraclePreparedStatement.executeUpdate(OraclePreparedStatement.java:3845)
at oracle.jdbc.driver.OraclePreparedStatementWrapper.executeUpdate(OraclePreparedStatementWrapper.java:1061)
at com.mchange.v2.c3p0.impl.NewProxyPreparedStatement.executeUpdate(NewProxyPreparedStatement.java:384)
at org.springframework.jdbc.core.JdbcTemplate.lambda$update$0(JdbcTemplate.java:855)
at org.springframework.jdbc.core.JdbcTemplate.execute(JdbcTemplate.java:605)
... 34 common frames omitted
Caused by: oracle.jdbc.OracleDatabaseException: ORA-00001: unique constraint (SPRING_SESSION_ATTRIBUTES_PK) violated
at oracle.jdbc.driver.T4CTTIoer11.processError(T4CTTIoer11.java:498)
... 51 common frames omitted
2018-12-13 12:18:24,089 ERROR [o.a.c.c.C.[.[.[.[jsp]] [http-nio-127.0.0.1-11450-exec-9]: Servlet.service() for servlet jsp threw exception
oracle.jdbc.OracleDatabaseException: ORA-00001: unique constraint (SPRING_SESSION_ATTRIBUTES_PK) violated
at oracle.jdbc.driver.T4CTTIoer11.processError(T4CTTIoer11.java:498)
at oracle.jdbc.driver.T4CTTIoer11.processError(T4CTTIoer11.java:446)
at oracle.jdbc.driver.T4C8Oall.processError(T4C8Oall.java:1054)
at oracle.jdbc.driver.T4CTTIfun.receive(T4CTTIfun.java:623)
at oracle.jdbc.driver.T4CTTIfun.doRPC(T4CTTIfun.java:252)
at oracle.jdbc.driver.T4C8Oall.doOALL(T4C8Oall.java:612)
at oracle.jdbc.driver.T4CPreparedStatement.doOall8(T4CPreparedStatement.java:226)
at oracle.jdbc.driver.T4CPreparedStatement.doOall8(T4CPreparedStatement.java:59)
at oracle.jdbc.driver.T4CPreparedStatement.executeForRows(T4CPreparedStatement.java:910)
at oracle.jdbc.driver.OracleStatement.doExecuteWithTimeout(OracleStatement.java:1119)
at oracle.jdbc.driver.OraclePreparedStatement.executeInternal(OraclePreparedStatement.java:3780)
at oracle.jdbc.driver.T4CPreparedStatement.executeInternal(T4CPreparedStatement.java:1343)
at oracle.jdbc.driver.OraclePreparedStatement.executeLargeUpdate(OraclePreparedStatement.java:3865)
at oracle.jdbc.driver.OraclePreparedStatement.executeUpdate(OraclePreparedStatement.java:3845)
at oracle.jdbc.driver.OraclePreparedStatementWrapper.executeUpdate(OraclePreparedStatementWrapper.java:1061)
at com.mchange.v2.c3p0.impl.NewProxyPreparedStatement.executeUpdate(NewProxyPreparedStatement.java:384)
at org.springframework.jdbc.core.JdbcTemplate.lambda$update$0(JdbcTemplate.java:855)
at org.springframework.jdbc.core.JdbcTemplate.execute(JdbcTemplate.java:605)
at org.springframework.jdbc.core.JdbcTemplate.update(JdbcTemplate.java:850)
at org.springframework.jdbc.core.JdbcTemplate.update(JdbcTemplate.java:905)
at org.springframework.session.jdbc.JdbcOperationsSessionRepository.insertSessionAttributes(JdbcOperationsSessionRepository.java:547)
at org.springframework.session.jdbc.JdbcOperationsSessionRepository.access$300(JdbcOperationsSessionRepository.java:135)
at org.springframework.session.jdbc.JdbcOperationsSessionRepository$3.doInTransactionWithoutResult(JdbcOperationsSessionRepository.java:442)
at org.springframework.transaction.support.TransactionCallbackWithoutResult.doInTransaction(TransactionCallbackWithoutResult.java:36)
at org.springframework.transaction.support.TransactionTemplate.execute(TransactionTemplate.java:140)
at org.springframework.session.jdbc.JdbcOperationsSessionRepository.save(JdbcOperationsSessionRepository.java:421)
at org.springframework.session.jdbc.JdbcOperationsSessionRepository.save(JdbcOperationsSessionRepository.java:135)
at org.springframework.session.web.http.SessionRepositoryFilter$SessionRepositoryRequestWrapper.commitSession(SessionRepositoryFilter.java:240)
at org.springframework.session.web.http.SessionRepositoryFilter$SessionRepositoryRequestWrapper.access$100(SessionRepositoryFilter.java:201)
at org.springframework.session.web.http.SessionRepositoryFilter$SessionRepositoryRequestWrapper$SessionCommittingRequestDispatcher.include(SessionRepositoryFilter.java:443)
at org.apache.jasper.runtime.JspRuntimeLibrary.include(JspRuntimeLibrary.java:884)
at org.apache.jasper.runtime.PageContextImpl.doInclude(PageContextImpl.java:679)
at org.apache.jasper.runtime.PageContextImpl.include(PageContextImpl.java:673)
at org.apache.tiles.request.jsp.JspRequest.doInclude(JspRequest.java:123)
at org.apache.tiles.request.AbstractViewRequest.dispatch(AbstractViewRequest.java:47)
at org.apache.tiles.request.render.DispatchRenderer.render(DispatchRenderer.java:47)
at org.apache.tiles.request.render.ChainedDelegateRenderer.render(ChainedDelegateRenderer.java:68)
at org.apache.tiles.impl.BasicTilesContainer.render(BasicTilesContainer.java:259)
at org.apache.tiles.TilesContainerWrapper.render(TilesContainerWrapper.java:108)
at org.apache.tiles.template.InsertAttributeModel.renderAttribute(InsertAttributeModel.java:188)
at org.apache.tiles.template.InsertAttributeModel.execute(InsertAttributeModel.java:132)
at org.apache.tiles.jsp.taglib.InsertAttributeTag.doTag(InsertAttributeTag.java:299)
at org.apache.jsp.WEB_002dINF.jsp.layout.main_005flayout_jsp._jspx_meth_tiles_005finsertAttribute_005f0(main_005flayout_jsp.java:677)
at org.apache.jsp.WEB_002dINF.jsp.layout.main_005flayout_jsp._jspService(main_005flayout_jsp.java:203)
at org.apache.jasper.runtime.HttpJspBase.service(HttpJspBase.java:70)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:729)
at org.apache.jasper.servlet.JspServletWrapper.service(JspServletWrapper.java:438)
at org.apache.jasper.servlet.JspServlet.serviceJspFile(JspServlet.java:396)
at org.apache.jasper.servlet.JspServlet.service(JspServlet.java:340)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:729)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:292)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:207)
at org.apache.struts2.dispatcher.filter.StrutsPrepareAndExecuteFilter.doFilter(StrutsPrepareAndExecuteFilter.java:138)
at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:357)
at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:270)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:240)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:207)
at org.springframework.session.web.http.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:75)
at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:357)
at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:270)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:240)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:207)
at org.apache.catalina.core.ApplicationDispatcher.invoke(ApplicationDispatcher.java:716)
at org.apache.catalina.core.ApplicationDispatcher.processRequest(ApplicationDispatcher.java:468)
at org.apache.catalina.core.ApplicationDispatcher.doForward(ApplicationDispatcher.java:391)
at org.apache.catalina.core.ApplicationDispatcher.forward(ApplicationDispatcher.java:318)
at org.springframework.session.web.http.SessionRepositoryFilter$SessionRepositoryRequestWrapper$SessionCommittingRequestDispatcher.forward(SessionRepositoryFilter.java:437)
at org.apache.tiles.request.servlet.ServletRequest.forward(ServletRequest.java:265)
at org.apache.tiles.request.servlet.ServletRequest.doForward(ServletRequest.java:228)
at org.apache.tiles.request.AbstractClientRequest.dispatch(AbstractClientRequest.java:57)
at org.apache.tiles.request.render.DispatchRenderer.render(DispatchRenderer.java:47)
at org.apache.tiles.impl.BasicTilesContainer.render(BasicTilesContainer.java:259)
at org.apache.tiles.impl.BasicTilesContainer.render(BasicTilesContainer.java:397)
at org.apache.tiles.impl.BasicTilesContainer.render(BasicTilesContainer.java:238)
at org.apache.tiles.TilesContainerWrapper.render(TilesContainerWrapper.java:103)
at org.apache.tiles.impl.mgmt.CachingTilesContainer.render(CachingTilesContainer.java:126)
at org.apache.struts2.views.tiles.TilesResult.doExecute(TilesResult.java:158)
at org.apache.struts2.result.StrutsResultSupport.execute(StrutsResultSupport.java:204)
at com.opensymphony.xwork2.DefaultActionInvocation.executeResult(DefaultActionInvocation.java:375)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:279)
at com.opensymphony.xwork2.interceptor.DefaultWorkflowInterceptor.doIntercept(DefaultWorkflowInterceptor.java:179)
at com.opensymphony.xwork2.interceptor.MethodFilterInterceptor.intercept(MethodFilterInterceptor.java:99)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:249)
at com.opensymphony.xwork2.validator.ValidationInterceptor.doIntercept(ValidationInterceptor.java:263)
at org.apache.struts2.interceptor.validation.AnnotationValidationInterceptor.doIntercept(AnnotationValidationInterceptor.java:49)
at com.opensymphony.xwork2.interceptor.MethodFilterInterceptor.intercept(MethodFilterInterceptor.java:99)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:249)
at com.opensymphony.xwork2.interceptor.ConversionErrorInterceptor.doIntercept(ConversionErrorInterceptor.java:142)
at com.opensymphony.xwork2.interceptor.MethodFilterInterceptor.intercept(MethodFilterInterceptor.java:99)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:249)
at com.opensymphony.xwork2.interceptor.ParametersInterceptor.doIntercept(ParametersInterceptor.java:137)
at com.opensymphony.xwork2.interceptor.MethodFilterInterceptor.intercept(MethodFilterInterceptor.java:99)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:249)
at com.opensymphony.xwork2.interceptor.ParametersInterceptor.doIntercept(ParametersInterceptor.java:137)
at com.opensymphony.xwork2.interceptor.MethodFilterInterceptor.intercept(MethodFilterInterceptor.java:99)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:249)
at com.opensymphony.xwork2.interceptor.StaticParametersInterceptor.intercept(StaticParametersInterceptor.java:201)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:249)
at org.apache.struts2.interceptor.FileUploadInterceptor.intercept(FileUploadInterceptor.java:243)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:249)
at com.opensymphony.xwork2.interceptor.ChainingInterceptor.intercept(ChainingInterceptor.java:160)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:249)
at com.opensymphony.xwork2.interceptor.PrepareInterceptor.doIntercept(PrepareInterceptor.java:175)
at com.opensymphony.xwork2.interceptor.MethodFilterInterceptor.intercept(MethodFilterInterceptor.java:99)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:249)
at com.etouchpoint.components.querybar.ui.QueryBarInterceptor.intercept(QueryBarInterceptor.java:210)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:249)
at com.etouchpoint.common.web.struts2.SpringSecurityUserAwareInterceptor.intercept(SpringSecurityUserAwareInterceptor.java:45)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:249)
at com.etouchpoint.common.web.struts2.TransactionInterceptor.intercept(TransactionInterceptor.java:49)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:249)
at org.apache.struts2.interceptor.ServletConfigInterceptor.intercept(ServletConfigInterceptor.java:167)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:249)
at com.opensymphony.xwork2.interceptor.ParametersInterceptor.doIntercept(ParametersInterceptor.java:137)
at com.opensymphony.xwork2.interceptor.MethodFilterInterceptor.intercept(MethodFilterInterceptor.java:99)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:249)
at com.opensymphony.xwork2.interceptor.ModelDrivenInterceptor.intercept(ModelDrivenInterceptor.java:101)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:249)
at org.apache.struts2.interceptor.CookieInterceptor.intercept(CookieInterceptor.java:261)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:249)
at org.apache.struts2.interceptor.MultiselectInterceptor.intercept(MultiselectInterceptor.java:67)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:249)
at org.apache.struts2.interceptor.CheckboxInterceptor.intercept(CheckboxInterceptor.java:85)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:249)
at org.apache.struts2.interceptor.I18nInterceptor.intercept(I18nInterceptor.java:121)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:249)
at com.opensymphony.xwork2.interceptor.AliasInterceptor.intercept(AliasInterceptor.java:203)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:249)
at com.opensymphony.xwork2.interceptor.ExceptionMappingInterceptor.intercept(ExceptionMappingInterceptor.java:196)
at com.opensymphony.xwork2.DefaultActionInvocation.invoke(DefaultActionInvocation.java:249)
at org.apache.struts2.factory.StrutsActionProxy.execute(StrutsActionProxy.java:48)
at org.apache.struts2.dispatcher.Dispatcher.serviceAction(Dispatcher.java:574)
at org.apache.struts2.dispatcher.ExecuteOperations.executeAction(ExecuteOperations.java:79)
at org.apache.struts2.dispatcher.filter.StrutsPrepareAndExecuteFilter.doFilter(StrutsPrepareAndExecuteFilter.java:141)
at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:357)
at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:270)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:240)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:207)
at org.springframework.session.web.http.SessionRepositoryFilter.doFilterInternal(SessionRepositoryFilter.java:151)
at org.springframework.session.web.http.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:81)
at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:357)
at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:270)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:240)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:207)
at org.apache.catalina.core.ApplicationDispatcher.invoke(ApplicationDispatcher.java:716)
at org.apache.catalina.core.ApplicationDispatcher.processRequest(ApplicationDispatcher.java:468)
at org.apache.catalina.core.ApplicationDispatcher.doForward(ApplicationDispatcher.java:391)
at org.apache.catalina.core.ApplicationDispatcher.forward(ApplicationDispatcher.java:318)
at org.apache.catalina.core.StandardHostValve.custom(StandardHostValve.java:442)
at org.apache.catalina.core.StandardHostValve.throwable(StandardHostValve.java:379)
at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:176)
at psiprobe.Tomcat80AgentValve.invoke(Tomcat80AgentValve.java:45)
at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:79)
at org.apache.catalina.valves.AbstractAccessLogValve.invoke(AbstractAccessLogValve.java:620)
at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:88)
at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:502)
at org.apache.coyote.http11.AbstractHttp11Processor.process(AbstractHttp11Processor.java:1156)
at org.apache.coyote.AbstractProtocol$AbstractConnectionHandler.process(AbstractProtocol.java:684)
at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1539)
at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.run(NioEndpoint.java:1495)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
at java.lang.Thread.run(Thread.java:748)
2018-12-13 12:18:24,136 ERROR [o.a.c.c.C.[.[.[.[default]] [http-nio-127.0.0.1-11450-exec-9]: Servlet.service() for servlet default threw exception
org.springframework.dao.DuplicateKeyException: PreparedStatementCallback; SQL [INSERT INTO SPRING_SESSION_ATTRIBUTES(SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES) SELECT PRIMARY_ID, ?, ? FROM SPRING_SESSION WHERE SESSION_ID = ?ORA-00001: unique constraint (SPRING_SESSION_ATTRIBUTES_PK) violated
; nested exception is java.sql.SQLIntegrityConstraintViolationException: ORA-00001: unique constraint (SPRING_SESSION_ATTRIBUTES_PK) violated
at org.springframework.jdbc.support.SQLErrorCodeSQLExceptionTranslator.doTranslate(SQLErrorCodeSQLExceptionTranslator.java:242)
at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:72)
at org.springframework.jdbc.core.JdbcTemplate.translateException(JdbcTemplate.java:1402)
at org.springframework.jdbc.core.JdbcTemplate.execute(JdbcTemplate.java:620)
at org.springframework.jdbc.core.JdbcTemplate.update(JdbcTemplate.java:850)
at org.springframework.jdbc.core.JdbcTemplate.update(JdbcTemplate.java:905)
at org.springframework.session.jdbc.JdbcOperationsSessionRepository.insertSessionAttributes(JdbcOperationsSessionRepository.java:547)
at org.springframework.session.jdbc.JdbcOperationsSessionRepository.access$300(JdbcOperationsSessionRepository.java:135)
at org.springframework.session.jdbc.JdbcOperationsSessionRepository$3.doInTransactionWithoutResult(JdbcOperationsSessionRepository.java:442)
at org.springframework.transaction.support.TransactionCallbackWithoutResult.doInTransaction(TransactionCallbackWithoutResult.java:36)
at org.springframework.transaction.support.TransactionTemplate.execute(TransactionTemplate.java:140)
at org.springframework.session.jdbc.JdbcOperationsSessionRepository.save(JdbcOperationsSessionRepository.java:421)
at org.springframework.session.jdbc.JdbcOperationsSessionRepository.save(JdbcOperationsSessionRepository.java:135)
at org.springframework.session.web.http.SessionRepositoryFilter$SessionRepositoryRequestWrapper.commitSession(SessionRepositoryFilter.java:240)
at org.springframework.session.web.http.SessionRepositoryFilter$SessionRepositoryRequestWrapper.access$100(SessionRepositoryFilter.java:201)
at org.springframework.session.web.http.SessionRepositoryFilter.doFilterInternal(SessionRepositoryFilter.java:154)
at org.springframework.session.web.http.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:81)
at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:357)
at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:270)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:240)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:207)
at org.apache.catalina.core.ApplicationDispatcher.invoke(ApplicationDispatcher.java:716)
at org.apache.catalina.core.ApplicationDispatcher.processRequest(ApplicationDispatcher.java:468)
at org.apache.catalina.core.ApplicationDispatcher.doForward(ApplicationDispatcher.java:391)
at org.apache.catalina.core.ApplicationDispatcher.forward(ApplicationDispatcher.java:318)
at org.apache.catalina.core.StandardHostValve.custom(StandardHostValve.java:442)
at org.apache.catalina.core.StandardHostValve.throwable(StandardHostValve.java:379)
at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:176)
at psiprobe.Tomcat80AgentValve.invoke(Tomcat80AgentValve.java:45)
at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:79)
at org.apache.catalina.valves.AbstractAccessLogValve.invoke(AbstractAccessLogValve.java:620)
at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:88)
at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:502)
at org.apache.coyote.http11.AbstractHttp11Processor.process(AbstractHttp11Processor.java:1156)
at org.apache.coyote.AbstractProtocol$AbstractConnectionHandler.process(AbstractProtocol.java:684)
at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1539)
at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.run(NioEndpoint.java:1495)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
at java.lang.Thread.run(Thread.java:748)
Caused by: java.sql.SQLIntegrityConstraintViolationException: ORA-00001: unique constraint (SPRING_SESSION_ATTRIBUTES_PK) violated
at oracle.jdbc.driver.T4CTTIoer11.processError(T4CTTIoer11.java:494)
at oracle.jdbc.driver.T4CTTIoer11.processError(T4CTTIoer11.java:446)
at oracle.jdbc.driver.T4C8Oall.processError(T4C8Oall.java:1054)
at oracle.jdbc.driver.T4CTTIfun.receive(T4CTTIfun.java:623)
at oracle.jdbc.driver.T4CTTIfun.doRPC(T4CTTIfun.java:252)
at oracle.jdbc.driver.T4C8Oall.doOALL(T4C8Oall.java:612)
at oracle.jdbc.driver.T4CPreparedStatement.doOall8(T4CPreparedStatement.java:226)
at oracle.jdbc.driver.T4CPreparedStatement.doOall8(T4CPreparedStatement.java:59)
at oracle.jdbc.driver.T4CPreparedStatement.executeForRows(T4CPreparedStatement.java:910)
at oracle.jdbc.driver.OracleStatement.doExecuteWithTimeout(OracleStatement.java:1119)
at oracle.jdbc.driver.OraclePreparedStatement.executeInternal(OraclePreparedStatement.java:3780)
at oracle.jdbc.driver.T4CPreparedStatement.executeInternal(T4CPreparedStatement.java:1343)
at oracle.jdbc.driver.OraclePreparedStatement.executeLargeUpdate(OraclePreparedStatement.java:3865)
at oracle.jdbc.driver.OraclePreparedStatement.executeUpdate(OraclePreparedStatement.java:3845)
at oracle.jdbc.driver.OraclePreparedStatementWrapper.executeUpdate(OraclePreparedStatementWrapper.java:1061)
at com.mchange.v2.c3p0.impl.NewProxyPreparedStatement.executeUpdate(NewProxyPreparedStatement.java:384)
at org.springframework.jdbc.core.JdbcTemplate.lambda$update$0(JdbcTemplate.java:855)
at org.springframework.jdbc.core.JdbcTemplate.execute(JdbcTemplate.java:605)
... 37 common frames omitted
Caused by: oracle.jdbc.OracleDatabaseException: ORA-00001: unique constraint (SPRING_SESSION_ATTRIBUTES_PK) violated
at oracle.jdbc.driver.T4CTTIoer11.processError(T4CTTIoer11.java:498)
... 54 common frames omitted

@pminearo
Copy link

I know you are trying to reduce the number of SQL queries for performance reasons. Looking at the code where we are getting the Exception from:

`private void insertSessionAttributes(JdbcSession session, List attributeNames) {
Assert.notEmpty(attributeNames, "attributeNames must not be null or empty");
if (attributeNames.size() > 1) {
this.jdbcOperations.batchUpdate(this.createSessionAttributeQuery, new BatchPreparedStatementSetter() {

				@Override
				public void setValues(PreparedStatement ps, int i) throws SQLException {
					String attributeName = attributeNames.get(i);
					ps.setString(1, attributeName);
					setObjectAsBlob(ps, 2, session.getAttribute(attributeName));
					ps.setString(3, session.getId());
				}

				@Override
				public int getBatchSize() {
					return attributeNames.size();
				}

	});
}
else {

// Exception is thrown from this command
this.jdbcOperations.update(this.createSessionAttributeQuery, (ps) -> {
String attributeName = attributeNames.get(0);
ps.setString(1, attributeName);
setObjectAsBlob(ps, 2, session.getAttribute(attributeName));
ps.setString(3, session.getId());
});
}
}`

Unless you do an "Insert or Update" type statement here; you pretty much have to either

  1. Catch the exception and then do an update, or ignore.
  2. Check to ensure the row does not exist before inserting.

If you can do "Insert or Update" great! But you may have to go the route of catching, or checking.

@vpavic
Copy link
Contributor

vpavic commented Dec 17, 2018

Thanks for following up on this issue @pminearo.

As explained in my previous comment, neither of the options is ideal and presents a certain compromise. That's why I'm hesitant to do any of those changes as we would negate some of the optimization efforts that have been made to JdbcOperationsSessionRepository.

Is there anything you could do on application level in order to reduce the likelihood of concurrent requests that operate on the same session attribute?

@pminearo
Copy link

Unfortunately no, it is out of our hands with when AJAX calls get made. We use a product called SmartClient on the front end and don't have much control over when it makes the calls. Plus, having sites ensure only 1 AJAX call is made at a time defeats a lot of the performance gains from making multiple calls at the same time.

Since the exception is caught and logged anyways; why would catching an exception and ignoring it be a compromise to performance? Wouldn't that be a slightly better performance because it is not getting logged which slows the performance? Could it be assumed that if Spring Session tries to insert an attribute that already exists; it has the same attribute value? I do not know what is actually being stored, but I would think it would be the same thing. If it is the same BLOB being stored, you could just ignore the exception? If you are worried about not showing an error, you could catch and log as Debug; that way if there is a problem we could turn on Debug Logging and see what it is.

The problem we have is we have no control over the logging of the error. This is done in Tomcat. Which, sets off our alerting system ; sending out false-positives.

@vpavic
Copy link
Contributor

vpavic commented Dec 17, 2018

Could it be assumed that if Spring Session tries to insert an attribute that already exists; it has the same attribute value?

That seems like a more likely of the two error handling scenarios described in my previous comment, however to implement that we'd have to drop our batching optimization.

@ovanekem
Copy link
Author

On my side, we indeed solved this from an application point of view. But also in my case I have full control on the client code (which not seems to be the case of @pminearo ).
Coming back to the roots of this issue, this is in fact bad design in the first place (as was also in our application) that there are multiple AJAX calls at once to store a value in a session variable with different values. Indeed, nothing can be predictable from a browser perspective that one call will really be executed before another (depends largely on how the server will handle thread pooling,...).

With this in mind, I think a potential workaround implemented in the framework directly would be to do an update when the insert fails. I do not think (but I have not tested ;-)). The risk of course is that the AJAX calls that did the insert in the first place was supposed to come second and this value will be erased, but this is a consequence of the initial bad design I guess. Now we need to check that this does not indeed fails the batch optimization.

@pminearo
Copy link

A quick update:

I pulled down the 2.1.2.RELEASE tag of the cod base. I then edited JdbcOperationsSessionRepository.insertSessionAttributes() to have a try/catch around the 'if/else' statement. Then logged what info I had. What's interesting about the output right now is the only Attribute Name listed was "SPRING_SECURITY_CONTEXT". Even though, when I look in the DB; I see multiple attributes for that user's session. The exception does happen right after they log in; which would make sense as to why there is only 1 attribute being inserted. So, far this quick hack is not causing any side affects. But that does not mean there aren't any. Just means I haven't seen, or heard, any. I will give you more updates as I see them.

@blacelle
Copy link

blacelle commented Jan 7, 2019

I encountered a similar stack on 2.1.2:

In happens in the context of spring-oauth2

ERROR (PSQLException): Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is 
org.springframework.dao.DuplicateKeyException: PreparedStatementCallback; SQL [INSERT INTO SPRING_SESSION_ATTRIBUTES(SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES) SELECT PRIMARY_ID, ?, ? FROM SPRING_SESSION WHERE SESSION_ID = ?]; ERROR: duplicate key value violates unique constraint "spring_session_attributes_pk" Detail: Key (session_primary_id, attribute_name)=(cde0debe-e6f4-460b-990c-d126ef0c79ae, 
org.springframework.security.oauth2.provider.endpoint.AuthorizationEndpoint.ORIGINAL_AUTHORIZATION_REQUEST) already exists.; nested exception is org.postgresql.util.PSQLException: ERROR: duplicate key value violates unique constraint "spring_session_attributes_pk" 
Detail: Key (session_primary_id, attribute_name)=(cde0debe-e6f4-460b-990c-d126ef0c79ae, org.springframework.security.oauth2.provider.endpoint.AuthorizationEndpoint.ORIGINAL_AUTHORIZATION_REQUEST) already exists.] with root cause
ERROR: duplicate key value violates unique constraint "spring_session_attributes_pk"

Key (session_primary_id, attribute_name)=(cde0debe-e6f4-460b-990c-d126ef0c79ae, org.springframework.security.oauth2.provider.endpoint.AuthorizationEndpoint.ORIGINAL_AUTHORIZATION_REQUEST) already exists.

org.postgresql.util.PSQLException: ERROR: duplicate key value violates unique constraint "spring_session_attributes_pk"
Detail: Key (session_primary_id, attribute_name)=(cde0debe-e6f4-460b-990c-d126ef0c79ae, org.springframework.security.oauth2.provider.endpoint.AuthorizationEndpoint.ORIGINAL_AUTHORIZATION_REQUEST) already exists.
at QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2440)
at QueryExecutorImpl.processResults(QueryExecutorImpl.java:2183)
at QueryExecutorImpl.execute(QueryExecutorImpl.java:481)
at PgStatement.executeBatch(PgStatement.java:840)
at PgPreparedStatement.executeBatch(PgPreparedStatement.java:1538)
at DelegatingStatement.executeBatch(DelegatingStatement.java:223)
at DelegatingStatement.executeBatch(DelegatingStatement.java:223)
at JdbcTemplate.lambda$batchUpdate$2(JdbcTemplate.java:950)
at JdbcTemplate.execute(JdbcTemplate.java:617)
at JdbcTemplate.execute(JdbcTemplate.java:646)
at JdbcTemplate.batchUpdate(JdbcTemplate.java:936)
at JdbcOperationsSessionRepository.insertSessionAttributes(JdbcOperationsSessionRepository.java:529)
at JdbcOperationsSessionRepository.access$300(JdbcOperationsSessionRepository.java:135)
at JdbcOperationsSessionRepository$3.doInTransactionWithoutResult(JdbcOperationsSessionRepository.java:442)
at TransactionCallbackWithoutResult.doInTransaction(TransactionCallbackWithoutResult.java:36)
at TransactionTemplate.execute(TransactionTemplate.java:140)
at JdbcOperationsSessionRepository.save(JdbcOperationsSessionRepository.java:421)
at JdbcOperationsSessionRepository.save(JdbcOperationsSessionRepository.java:135)
at SessionRepositoryFilter$SessionRepositoryRequestWrapper.commitSession(SessionRepositoryFilter.java:240)
at SessionRepositoryFilter$SessionRepositoryRequestWrapper.access$100(SessionRepositoryFilter.java:201)
at SessionRepositoryFilter$SessionRepositoryResponseWrapper.onResponseCommitted(SessionRepositoryFilter.java:189)
at OnCommittedResponseWrapper.doOnResponseCommitted(OnCommittedResponseWrapper.java:227)
at OnCommittedResponseWrapper.sendRedirect(OnCommittedResponseWrapper.java:127)

I initially felt it might be related to #1031 but @vpavic suggests my case might rather be related to this ticket. Given this (production) stack, do you feel this is the same issue ? (I'm not able to pronounce myself). If no, I shall create a dedicated ticket but I doubt I will be able to provide a reproduction case.

@vpavic vpavic added the type: bug A general bug label Jan 12, 2019
@benzen
Copy link

benzen commented Aug 19, 2020

By reading this thread, i understand that's It's an issue that is more complicated that it could appears.
But It's been two year almost. A workaround is mentioned, but I didn't see any clear explanantion of what we as consumer of this project could do.

Even if the smarter SQL need to be stored in my project, I would do it without hesitation, but at least guide on the way to follow to fix this.

@candrews
Copy link
Contributor

I wish #1481 could be reconsidered... imho it's a really good solution for this problem.

@hubbaba
Copy link

hubbaba commented Aug 21, 2020

It would be really helpful if someone could write up a guide or point to a guide explaining how to eliminate this error. I've been experiencing this in production for a long time and I've just grown tired of my log filling up with these errors. I run spring boot with spring session jdbc, nothing special.

It's time to solve this problem once and for all. I appreciate the help!

@shark300
Copy link

shark300 commented Sep 26, 2020

@hubbaba I'm using this workaround for a while. For DB specific inserts, check candrews's commits.

import javax.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Configuration;
import org.springframework.session.jdbc.JdbcIndexedSessionRepository;
import org.springframework.util.StringUtils;

/**
 * see: <a
 * href="https://github.com/spring-projects/spring-session/issues/1213#issuecomment-516466012">When
 * you fire multiple AJAX calls to a controller and call setAttribute you can get a ERROR: duplicate
 * key value violates unique constraint "spring_session_attributes_pk"</a>
 */
@Configuration
@ConditionalOnProperty(value = "spring.session.use-mysql-specific-insert", havingValue = "true")
public class CustomSessionInsertConfigurator {
  private static final String CREATE_SESSION_ATTRIBUTE_QUERY_ON_DUPLICATE_KEY_UPDATE =
      "INSERT INTO %TABLE_NAME%_ATTRIBUTES(SESSION_PRIMARY_ID, ATTRIBUTE_NAME,  ATTRIBUTE_BYTES) "
          + "SELECT PRIMARY_ID, ?, ? "
          + "FROM %TABLE_NAME% "
          + "WHERE SESSION_ID = ? ON DUPLICATE KEY UPDATE ATTRIBUTE_BYTES=VALUES(ATTRIBUTE_BYTES)";

  private final JdbcIndexedSessionRepository originalRepository;

  @Autowired
  public CustomSessionInsertConfigurator(JdbcIndexedSessionRepository originalRepository) {
    this.originalRepository = originalRepository;
  }

  @PostConstruct
  public void customizedJdbcOperationsSessionRepository() {
    originalRepository.setCreateSessionAttributeQuery(
        StringUtils.replace(
            CREATE_SESSION_ATTRIBUTE_QUERY_ON_DUPLICATE_KEY_UPDATE,
            "%TABLE_NAME%",
            JdbcIndexedSessionRepository.DEFAULT_TABLE_NAME));
  }
}

@jkuipers
Copy link
Contributor

jkuipers commented Oct 3, 2020

Here's what I ended up using for MS SQL Server to fix both this issue as well as #1550:

/*
 * Copyright 2014-2019 the original author or authors.
 *
 * 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
 *
 *      https://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 org.springframework.session.jdbc;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.TypeDescriptor;
import org.springframework.core.convert.support.GenericConversionService;
import org.springframework.core.serializer.support.DeserializingConverter;
import org.springframework.core.serializer.support.SerializingConverter;
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.BatchPreparedStatementSetter;
import org.springframework.jdbc.core.JdbcOperations;
import org.springframework.jdbc.core.ResultSetExtractor;
import org.springframework.jdbc.support.lob.DefaultLobHandler;
import org.springframework.jdbc.support.lob.LobHandler;
import org.springframework.session.DelegatingIndexResolver;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.session.FlushMode;
import org.springframework.session.IndexResolver;
import org.springframework.session.MapSession;
import org.springframework.session.PrincipalNameIndexResolver;
import org.springframework.session.SaveMode;
import org.springframework.session.Session;
import org.springframework.transaction.support.TransactionOperations;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;

import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.function.Supplier;
import java.util.stream.Collectors;

/**
 * <b>Updated to fix deadlocks in MS SQL Server when concurrently saving new sessions!</b>.
 * <p>
 * A {@link org.springframework.session.SessionRepository} implementation that uses
 * Spring's {@link JdbcOperations} to store sessions in a relational database. This
 * implementation does not support publishing of session events.
 * <p>
 * An example of how to create a new instance can be seen below:
 *
 * <pre class="code">
 * JdbcTemplate jdbcTemplate = new JdbcTemplate();
 *
 * // ... configure jdbcTemplate ...
 *
 * TransactionTemplate transactionTemplate = new TransactionTemplate();
 *
 * // ... configure transactionTemplate ...
 *
 * JdbcIndexedSessionRepository sessionRepository =
 *         new JdbcIndexedSessionRepository(jdbcTemplate, transactionTemplate);
 * </pre>
 *
 * For additional information on how to create and configure {@code JdbcTemplate} and
 * {@code TransactionTemplate}, refer to the <a href=
 * "https://docs.spring.io/spring/docs/current/spring-framework-reference/html/spring-data-tier.html">
 * Spring Framework Reference Documentation</a>.
 * <p>
 * By default, this implementation uses <code>SPRING_SESSION</code> and
 * <code>SPRING_SESSION_ATTRIBUTES</code> tables to store sessions. Note that the table
 * name can be customized using the {@link #setTableName(String)} method. In that case the
 * table used to store attributes will be named using the provided table name, suffixed
 * with <code>_ATTRIBUTES</code>.
 *
 * Depending on your database, the table definition can be described as below:
 *
 * <pre class="code">
 * CREATE TABLE SPRING_SESSION (
 *   PRIMARY_ID CHAR(36) NOT NULL,
 *   SESSION_ID CHAR(36) NOT NULL,
 *   CREATION_TIME BIGINT NOT NULL,
 *   LAST_ACCESS_TIME BIGINT NOT NULL,
 *   MAX_INACTIVE_INTERVAL INT NOT NULL,
 *   EXPIRY_TIME BIGINT NOT NULL,
 *   PRINCIPAL_NAME VARCHAR(100),
 *   CONSTRAINT SPRING_SESSION_PK PRIMARY KEY (PRIMARY_ID)
 * );
 *
 * CREATE UNIQUE INDEX SPRING_SESSION_IX1 ON SPRING_SESSION (SESSION_ID);
 * CREATE INDEX SPRING_SESSION_IX1 ON SPRING_SESSION (EXPIRY_TIME);
 * CREATE INDEX SPRING_SESSION_IX3 ON SPRING_SESSION (PRINCIPAL_NAME);
 *
 * CREATE TABLE SPRING_SESSION_ATTRIBUTES (
 *  SESSION_PRIMARY_ID CHAR(36) NOT NULL,
 *  ATTRIBUTE_NAME VARCHAR(200) NOT NULL,
 *  ATTRIBUTE_BYTES BYTEA NOT NULL,
 *  CONSTRAINT SPRING_SESSION_ATTRIBUTES_PK PRIMARY KEY (SESSION_PRIMARY_ID, ATTRIBUTE_NAME),
 *  CONSTRAINT SPRING_SESSION_ATTRIBUTES_FK FOREIGN KEY (SESSION_PRIMARY_ID) REFERENCES SPRING_SESSION(PRIMARY_ID) ON DELETE CASCADE
 * );
 *
 * CREATE INDEX SPRING_SESSION_ATTRIBUTES_IX1 ON SPRING_SESSION_ATTRIBUTES (SESSION_PRIMARY_ID);
 * </pre>
 *
 * Due to the differences between the various database vendors, especially when it comes
 * to storing binary data, make sure to use SQL script specific to your database. Scripts
 * for most major database vendors are packaged as
 * <code>org/springframework/session/jdbc/schema-*.sql</code>, where <code>*</code> is the
 * target database type.
 *
 * @author Vedran Pavic
 * @author Craig Andrews
 * @since 2.2.0
 */
public class JdbcIndexedSessionRepository
		implements FindByIndexNameSessionRepository<JdbcIndexedSessionRepository.JdbcSession> {

	/**
	 * The default name of database table used by Spring Session to store sessions.
	 */
	public static final String DEFAULT_TABLE_NAME = "SPRING_SESSION";

	private static final String SPRING_SECURITY_CONTEXT = "SPRING_SECURITY_CONTEXT";

	// @formatter:off
	private static final String CREATE_SESSION_QUERY = "INSERT INTO %TABLE_NAME%(PRIMARY_ID, SESSION_ID, CREATION_TIME, LAST_ACCESS_TIME, MAX_INACTIVE_INTERVAL, EXPIRY_TIME, PRINCIPAL_NAME) "
			+ "VALUES (?, ?, ?, ?, ?, ?, ?)";
	// @formatter:on

	// @formatter:off
	private static final String GET_SESSION_QUERY = "SELECT S.PRIMARY_ID, S.SESSION_ID, S.CREATION_TIME, S.LAST_ACCESS_TIME, S.MAX_INACTIVE_INTERVAL, SA.ATTRIBUTE_NAME, SA.ATTRIBUTE_BYTES "
			+ "FROM %TABLE_NAME% S "
			+ "LEFT OUTER JOIN %TABLE_NAME%_ATTRIBUTES SA ON S.PRIMARY_ID = SA.SESSION_PRIMARY_ID "
			+ "WHERE S.SESSION_ID = ?";
	// @formatter:on

	// @formatter:off
	private static final String UPDATE_SESSION_QUERY = "UPDATE %TABLE_NAME% SET SESSION_ID = ?, LAST_ACCESS_TIME = ?, MAX_INACTIVE_INTERVAL = ?, EXPIRY_TIME = ?, PRINCIPAL_NAME = ? "
			+ "WHERE PRIMARY_ID = ?";
	// @formatter:on

	// @formatter:off
	// FIX Joris: merged INSERT and CREATE queries into one, and ensured to use session primary key directly, rather than session ID with nested select
	private static final String UPSERT_SESSION_ATTRIBUTE_QUERY = "MERGE %TABLE_NAME%_ATTRIBUTES AS myTarget "
			+ "  USING (SELECT ? SESSION_PRIMARY_ID, ? ATTRIBUTE_NAME, ? ATTRIBUTE_BYTES) AS mySource "
			+ "  ON mySource.SESSION_PRIMARY_ID = myTarget.SESSION_PRIMARY_ID AND mySource.ATTRIBUTE_NAME = myTarget.ATTRIBUTE_NAME "
			+ "WHEN MATCHED THEN UPDATE SET ATTRIBUTE_BYTES = mySource.ATTRIBUTE_BYTES "
			+ "WHEN NOT MATCHED THEN INSERT (SESSION_PRIMARY_ID, ATTRIBUTE_NAME, ATTRIBUTE_BYTES) "
			+ "  VALUES (mySource.SESSION_PRIMARY_ID, mySource.ATTRIBUTE_NAME, mySource.ATTRIBUTE_BYTES);";
	// @formatter:on

	// @formatter:off
	private static final String DELETE_SESSION_ATTRIBUTE_QUERY = "DELETE FROM %TABLE_NAME%_ATTRIBUTES "
			+ "WHERE SESSION_PRIMARY_ID = ? "
			+ "AND ATTRIBUTE_NAME = ?";
	// @formatter:on

	// @formatter:off
	private static final String DELETE_SESSION_QUERY = "DELETE FROM %TABLE_NAME% "
			+ "WHERE SESSION_ID = ?";
	// @formatter:on

	// @formatter:off
	private static final String LIST_SESSIONS_BY_PRINCIPAL_NAME_QUERY = "SELECT S.PRIMARY_ID, S.SESSION_ID, S.CREATION_TIME, S.LAST_ACCESS_TIME, S.MAX_INACTIVE_INTERVAL, SA.ATTRIBUTE_NAME, SA.ATTRIBUTE_BYTES "
			+ "FROM %TABLE_NAME% S "
			+ "LEFT OUTER JOIN %TABLE_NAME%_ATTRIBUTES SA ON S.PRIMARY_ID = SA.SESSION_PRIMARY_ID "
			+ "WHERE S.PRINCIPAL_NAME = ?";
	// @formatter:on

	// @formatter:off
	private static final String DELETE_SESSIONS_BY_EXPIRY_TIME_QUERY = "DELETE FROM %TABLE_NAME% "
			+ "WHERE EXPIRY_TIME < ?";
	// @formatter:on

	private static final Log logger = LogFactory.getLog(JdbcIndexedSessionRepository.class);

	private final JdbcOperations jdbcOperations;

	private final TransactionOperations transactionOperations;

	private final ResultSetExtractor<List<JdbcSession>> extractor = new SessionResultSetExtractor();

	/**
	 * The name of database table used by Spring Session to store sessions.
	 */
	private String tableName = DEFAULT_TABLE_NAME;

	private String createSessionQuery;

	private String getSessionQuery;

	private String updateSessionQuery;

	private String upsertSessionAttributeQuery;

	private String deleteSessionAttributeQuery;

	private String deleteSessionQuery;

	private String listSessionsByPrincipalNameQuery;

	private String deleteSessionsByExpiryTimeQuery;

	/**
	 * If non-null, this value is used to override the default value for
	 * {@link JdbcSession#setMaxInactiveInterval(Duration)}.
	 */
	private Integer defaultMaxInactiveInterval;

	private IndexResolver<Session> indexResolver = new DelegatingIndexResolver<>(new PrincipalNameIndexResolver<>());

	private ConversionService conversionService = createDefaultConversionService();

	private LobHandler lobHandler = new DefaultLobHandler();

	private FlushMode flushMode = FlushMode.ON_SAVE;

	private SaveMode saveMode = SaveMode.ON_SET_ATTRIBUTE;

	/**
	 * Create a new {@link JdbcIndexedSessionRepository} instance which uses the provided
	 * {@link JdbcOperations} and {@link TransactionOperations} to manage sessions.
	 * @param jdbcOperations the {@link JdbcOperations} to use
	 * @param transactionOperations the {@link TransactionOperations} to use
	 */
	public JdbcIndexedSessionRepository(JdbcOperations jdbcOperations, TransactionOperations transactionOperations) {
		Assert.notNull(jdbcOperations, "jdbcOperations must not be null");
		Assert.notNull(transactionOperations, "transactionOperations must not be null");
		this.jdbcOperations = jdbcOperations;
		this.transactionOperations = transactionOperations;
		prepareQueries();
	}

	/**
	 * Set the name of database table used to store sessions.
	 * @param tableName the database table name
	 */
	public void setTableName(String tableName) {
		Assert.hasText(tableName, "Table name must not be empty");
		this.tableName = tableName.trim();
		prepareQueries();
	}

	/**
	 * Set the maximum inactive interval in seconds between requests before newly created
	 * sessions will be invalidated. A negative time indicates that the session will never
	 * timeout. The default is 1800 (30 minutes).
	 * @param defaultMaxInactiveInterval the maximum inactive interval in seconds
	 */
	public void setDefaultMaxInactiveInterval(Integer defaultMaxInactiveInterval) {
		this.defaultMaxInactiveInterval = defaultMaxInactiveInterval;
	}

	/**
	 * Set the {@link IndexResolver} to use.
	 * @param indexResolver the index resolver
	 */
	public void setIndexResolver(IndexResolver<Session> indexResolver) {
		Assert.notNull(indexResolver, "indexResolver cannot be null");
		this.indexResolver = indexResolver;
	}

	public void setLobHandler(LobHandler lobHandler) {
		Assert.notNull(lobHandler, "LobHandler must not be null");
		this.lobHandler = lobHandler;
	}

	/**
	 * Sets the {@link ConversionService} to use.
	 * @param conversionService the converter to set
	 */
	public void setConversionService(ConversionService conversionService) {
		Assert.notNull(conversionService, "conversionService must not be null");
		this.conversionService = conversionService;
	}

	/**
	 * Set the flush mode. Default is {@link FlushMode#ON_SAVE}.
	 * @param flushMode the flush mode
	 */
	public void setFlushMode(FlushMode flushMode) {
		Assert.notNull(flushMode, "flushMode must not be null");
		this.flushMode = flushMode;
	}

	/**
	 * Set the save mode.
	 * @param saveMode the save mode
	 */
	public void setSaveMode(SaveMode saveMode) {
		Assert.notNull(saveMode, "saveMode must not be null");
		this.saveMode = saveMode;
	}

	@Override
	public JdbcSession createSession() {
		MapSession delegate = new MapSession();
		if (this.defaultMaxInactiveInterval != null) {
			delegate.setMaxInactiveInterval(Duration.ofSeconds(this.defaultMaxInactiveInterval));
		}
		JdbcSession session = new JdbcSession(delegate, UUID.randomUUID().toString(), true);
		session.flushIfRequired();
		return session;
	}

	@Override
	public void save(final JdbcSession session) {
		session.save();
	}

	@Override
	public JdbcSession findById(final String id) {
		final JdbcSession session = this.transactionOperations.execute((status) -> {
			List<JdbcSession> sessions = JdbcIndexedSessionRepository.this.jdbcOperations.query(
					JdbcIndexedSessionRepository.this.getSessionQuery, (ps) -> ps.setString(1, id),
					JdbcIndexedSessionRepository.this.extractor);
			if (sessions.isEmpty()) {
				return null;
			}
			return sessions.get(0);
		});

		if (session != null) {
			if (session.isExpired()) {
				deleteById(id);
			}
			else {
				return session;
			}
		}
		return null;
	}

	@Override
	public void deleteById(final String id) {
		this.transactionOperations.executeWithoutResult((status) -> JdbcIndexedSessionRepository.this.jdbcOperations
				.update(JdbcIndexedSessionRepository.this.deleteSessionQuery, id));
	}

	@Override
	public Map<String, JdbcSession> findByIndexNameAndIndexValue(String indexName, final String indexValue) {
		if (!PRINCIPAL_NAME_INDEX_NAME.equals(indexName)) {
			return Collections.emptyMap();
		}

		List<JdbcSession> sessions = this.transactionOperations
				.execute((status) -> JdbcIndexedSessionRepository.this.jdbcOperations.query(
						JdbcIndexedSessionRepository.this.listSessionsByPrincipalNameQuery,
						(ps) -> ps.setString(1, indexValue), JdbcIndexedSessionRepository.this.extractor));

		Map<String, JdbcSession> sessionMap = new HashMap<>(sessions.size());

		for (JdbcSession session : sessions) {
			sessionMap.put(session.getId(), session);
		}

		return sessionMap;
	}

	private void upsertSessionAttributes(JdbcSession session, List<String> attributeNames) {
		Assert.notEmpty(attributeNames, "attributeNames must not be null or empty");
		if (attributeNames.size() > 1) {
			this.jdbcOperations.batchUpdate(this.upsertSessionAttributeQuery, new BatchPreparedStatementSetter() {

				@Override
				public void setValues(PreparedStatement ps, int i) throws SQLException {
					// FIX Joris: use primary key rather than session ID, and move it to the front
					ps.setString(1, session.primaryKey);
					String attributeName = attributeNames.get(i);
					ps.setString(2, attributeName);
					getLobHandler().getLobCreator().setBlobAsBytes(ps, 3,
							serialize(session.getAttribute(attributeName)));
				}

				@Override
				public int getBatchSize() {
					return attributeNames.size();
				}

			});
		}
		else {
			this.jdbcOperations.update(this.upsertSessionAttributeQuery, (ps) -> {
				// FIX Joris: use primary key rather than session ID, and move it to the front
				ps.setString(1, session.primaryKey);
				String attributeName = attributeNames.get(0);
				ps.setString(2, attributeName);
				getLobHandler().getLobCreator().setBlobAsBytes(ps, 3, serialize(session.getAttribute(attributeName)));
			});
		}
	}

	private void deleteSessionAttributes(JdbcSession session, List<String> attributeNames) {
		Assert.notEmpty(attributeNames, "attributeNames must not be null or empty");
		if (attributeNames.size() > 1) {
			this.jdbcOperations.batchUpdate(this.deleteSessionAttributeQuery, new BatchPreparedStatementSetter() {

				@Override
				public void setValues(PreparedStatement ps, int i) throws SQLException {
					String attributeName = attributeNames.get(i);
					ps.setString(1, session.primaryKey);
					ps.setString(2, attributeName);
				}

				@Override
				public int getBatchSize() {
					return attributeNames.size();
				}

			});
		}
		else {
			this.jdbcOperations.update(this.deleteSessionAttributeQuery, (ps) -> {
				String attributeName = attributeNames.get(0);
				ps.setString(1, session.primaryKey);
				ps.setString(2, attributeName);
			});
		}
	}

	public void cleanUpExpiredSessions() {
		Integer deletedCount = this.transactionOperations
				.execute((status) -> JdbcIndexedSessionRepository.this.jdbcOperations.update(
						JdbcIndexedSessionRepository.this.deleteSessionsByExpiryTimeQuery, System.currentTimeMillis()));

		if (logger.isDebugEnabled()) {
			logger.debug("Cleaned up " + deletedCount + " expired sessions");
		}
	}

	private static GenericConversionService createDefaultConversionService() {
		GenericConversionService converter = new GenericConversionService();
		converter.addConverter(Object.class, byte[].class, new SerializingConverter());
		converter.addConverter(byte[].class, Object.class, new DeserializingConverter());
		return converter;
	}

	private String getQuery(String base) {
		return StringUtils.replace(base, "%TABLE_NAME%", this.tableName);
	}

	private void prepareQueries() {
		this.createSessionQuery = getQuery(CREATE_SESSION_QUERY);
		this.getSessionQuery = getQuery(GET_SESSION_QUERY);
		this.updateSessionQuery = getQuery(UPDATE_SESSION_QUERY);
		this.upsertSessionAttributeQuery = getQuery(UPSERT_SESSION_ATTRIBUTE_QUERY);
		this.deleteSessionAttributeQuery = getQuery(DELETE_SESSION_ATTRIBUTE_QUERY);
		this.deleteSessionQuery = getQuery(DELETE_SESSION_QUERY);
		this.listSessionsByPrincipalNameQuery = getQuery(LIST_SESSIONS_BY_PRINCIPAL_NAME_QUERY);
		this.deleteSessionsByExpiryTimeQuery = getQuery(DELETE_SESSIONS_BY_EXPIRY_TIME_QUERY);
	}

	private LobHandler getLobHandler() {
		return this.lobHandler;
	}

	private byte[] serialize(Object object) {
		return (byte[]) this.conversionService.convert(object, TypeDescriptor.valueOf(Object.class),
				TypeDescriptor.valueOf(byte[].class));
	}

	private Object deserialize(byte[] bytes) {
		return this.conversionService.convert(bytes, TypeDescriptor.valueOf(byte[].class),
				TypeDescriptor.valueOf(Object.class));
	}

	private enum DeltaValue {

		ADDED, UPDATED, REMOVED

	}

	private static <T> Supplier<T> value(T value) {
		return (value != null) ? () -> value : null;
	}

	private static <T> Supplier<T> lazily(Supplier<T> supplier) {
		Supplier<T> lazySupplier = new Supplier<T>() {

			private T value;

			@Override
			public T get() {
				if (this.value == null) {
					this.value = supplier.get();
				}
				return this.value;
			}

		};

		return (supplier != null) ? lazySupplier : null;
	}

	/**
	 * The {@link Session} to use for {@link JdbcIndexedSessionRepository}.
	 *
	 * @author Vedran Pavic
	 */
	final class JdbcSession implements Session {

		private final Session delegate;

		private final String primaryKey;

		private boolean isNew;

		private boolean changed;

		private Map<String, DeltaValue> delta = new HashMap<>();

		JdbcSession(MapSession delegate, String primaryKey, boolean isNew) {
			this.delegate = delegate;
			this.primaryKey = primaryKey;
			this.isNew = isNew;
			if (this.isNew || (JdbcIndexedSessionRepository.this.saveMode == SaveMode.ALWAYS)) {
				getAttributeNames().forEach((attributeName) -> this.delta.put(attributeName, DeltaValue.UPDATED));
			}
		}

		boolean isNew() {
			return this.isNew;
		}

		boolean isChanged() {
			return this.changed;
		}

		Map<String, DeltaValue> getDelta() {
			return this.delta;
		}

		void clearChangeFlags() {
			this.isNew = false;
			this.changed = false;
			this.delta.clear();
		}

		Instant getExpiryTime() {
			return getLastAccessedTime().plus(getMaxInactiveInterval());
		}

		@Override
		public String getId() {
			return this.delegate.getId();
		}

		@Override
		public String changeSessionId() {
			this.changed = true;
			return this.delegate.changeSessionId();
		}

		@Override
		public <T> T getAttribute(String attributeName) {
			Supplier<T> supplier = this.delegate.getAttribute(attributeName);
			if (supplier == null) {
				return null;
			}
			T attributeValue = supplier.get();
			if (attributeValue != null
					&& JdbcIndexedSessionRepository.this.saveMode.equals(SaveMode.ON_GET_ATTRIBUTE)) {
				this.delta.put(attributeName, DeltaValue.UPDATED);
			}
			return attributeValue;
		}

		@Override
		public Set<String> getAttributeNames() {
			return this.delegate.getAttributeNames();
		}

		@Override
		public void setAttribute(String attributeName, Object attributeValue) {
			boolean attributeExists = (this.delegate.getAttribute(attributeName) != null);
			boolean attributeRemoved = (attributeValue == null);
			if (!attributeExists && attributeRemoved) {
				return;
			}
			if (attributeExists) {
				if (attributeRemoved) {
					this.delta.merge(attributeName, DeltaValue.REMOVED,
							(oldDeltaValue, deltaValue) -> (oldDeltaValue == DeltaValue.ADDED) ? null : deltaValue);
				}
				else {
					this.delta.merge(attributeName, DeltaValue.UPDATED, (oldDeltaValue,
																		 deltaValue) -> (oldDeltaValue == DeltaValue.ADDED) ? oldDeltaValue : deltaValue);
				}
			}
			else {
				this.delta.merge(attributeName, DeltaValue.ADDED, (oldDeltaValue,
																   deltaValue) -> (oldDeltaValue == DeltaValue.ADDED) ? oldDeltaValue : DeltaValue.UPDATED);
			}
			this.delegate.setAttribute(attributeName, value(attributeValue));
			if (PRINCIPAL_NAME_INDEX_NAME.equals(attributeName) || SPRING_SECURITY_CONTEXT.equals(attributeName)) {
				this.changed = true;
			}
			flushIfRequired();
		}

		@Override
		public void removeAttribute(String attributeName) {
			setAttribute(attributeName, null);
		}

		@Override
		public Instant getCreationTime() {
			return this.delegate.getCreationTime();
		}

		@Override
		public void setLastAccessedTime(Instant lastAccessedTime) {
			this.delegate.setLastAccessedTime(lastAccessedTime);
			this.changed = true;
			flushIfRequired();
		}

		@Override
		public Instant getLastAccessedTime() {
			return this.delegate.getLastAccessedTime();
		}

		@Override
		public void setMaxInactiveInterval(Duration interval) {
			this.delegate.setMaxInactiveInterval(interval);
			this.changed = true;
			flushIfRequired();
		}

		@Override
		public Duration getMaxInactiveInterval() {
			return this.delegate.getMaxInactiveInterval();
		}

		@Override
		public boolean isExpired() {
			return this.delegate.isExpired();
		}

		private void flushIfRequired() {
			if (JdbcIndexedSessionRepository.this.flushMode == FlushMode.IMMEDIATE) {
				save();
			}
		}

		private void save() {
			if (this.isNew) {
				JdbcIndexedSessionRepository.this.transactionOperations.executeWithoutResult((status) -> {
					Map<String, String> indexes = JdbcIndexedSessionRepository.this.indexResolver
							.resolveIndexesFor(JdbcSession.this);
					JdbcIndexedSessionRepository.this.jdbcOperations
							.update(JdbcIndexedSessionRepository.this.createSessionQuery, (ps) -> {
								ps.setString(1, JdbcSession.this.primaryKey);
								ps.setString(2, getId());
								ps.setLong(3, getCreationTime().toEpochMilli());
								ps.setLong(4, getLastAccessedTime().toEpochMilli());
								ps.setInt(5, (int) getMaxInactiveInterval().getSeconds());
								ps.setLong(6, getExpiryTime().toEpochMilli());
								ps.setString(7, indexes.get(PRINCIPAL_NAME_INDEX_NAME));
							});
					Set<String> attributeNames = getAttributeNames();
					if (!attributeNames.isEmpty()) {
						upsertSessionAttributes(JdbcSession.this, new ArrayList<>(attributeNames));
					}
				});
			}
			else {
				JdbcIndexedSessionRepository.this.transactionOperations.executeWithoutResult((status) -> {
					if (JdbcSession.this.changed) {
						Map<String, String> indexes = JdbcIndexedSessionRepository.this.indexResolver
								.resolveIndexesFor(JdbcSession.this);
						JdbcIndexedSessionRepository.this.jdbcOperations
								.update(JdbcIndexedSessionRepository.this.updateSessionQuery, (ps) -> {
									ps.setString(1, getId());
									ps.setLong(2, getLastAccessedTime().toEpochMilli());
									ps.setInt(3, (int) getMaxInactiveInterval().getSeconds());
									ps.setLong(4, getExpiryTime().toEpochMilli());
									ps.setString(5, indexes.get(PRINCIPAL_NAME_INDEX_NAME));
									ps.setString(6, JdbcSession.this.primaryKey);
								});
					}
					List<String> addedAndUpdatedAttributeNames = JdbcSession.this.delta.entrySet().stream()
							.filter(entry -> EnumSet.of(DeltaValue.ADDED, DeltaValue.UPDATED).contains(entry.getValue()))
							.map(Map.Entry::getKey)
							.collect(Collectors.toList());
					if (!addedAndUpdatedAttributeNames.isEmpty()) {
						upsertSessionAttributes(JdbcSession.this, addedAndUpdatedAttributeNames);
					}
					List<String> removedAttributeNames = JdbcSession.this.delta.entrySet().stream()
							.filter((entry) -> entry.getValue() == DeltaValue.REMOVED).map(Map.Entry::getKey)
							.collect(Collectors.toList());
					if (!removedAttributeNames.isEmpty()) {
						deleteSessionAttributes(JdbcSession.this, removedAttributeNames);
					}
				});
			}
			clearChangeFlags();
		}

	}

	private class SessionResultSetExtractor implements ResultSetExtractor<List<JdbcSession>> {

		@Override
		public List<JdbcSession> extractData(ResultSet rs) throws SQLException, DataAccessException {
			List<JdbcSession> sessions = new ArrayList<>();
			while (rs.next()) {
				String id = rs.getString("SESSION_ID");
				JdbcSession session;
				if (sessions.size() > 0 && getLast(sessions).getId().equals(id)) {
					session = getLast(sessions);
				}
				else {
					MapSession delegate = new MapSession(id);
					String primaryKey = rs.getString("PRIMARY_ID");
					delegate.setCreationTime(Instant.ofEpochMilli(rs.getLong("CREATION_TIME")));
					delegate.setLastAccessedTime(Instant.ofEpochMilli(rs.getLong("LAST_ACCESS_TIME")));
					delegate.setMaxInactiveInterval(Duration.ofSeconds(rs.getInt("MAX_INACTIVE_INTERVAL")));
					session = new JdbcSession(delegate, primaryKey, false);
				}
				String attributeName = rs.getString("ATTRIBUTE_NAME");
				if (attributeName != null) {
					byte[] bytes = getLobHandler().getBlobAsBytes(rs, "ATTRIBUTE_BYTES");
					session.delegate.setAttribute(attributeName, lazily(() -> deserialize(bytes)));
				}
				sessions.add(session);
			}
			return sessions;
		}

		private JdbcSession getLast(List<JdbcSession> sessions) {
			return sessions.get(sessions.size() - 1);
		}

	}

}

@parminderjit-s
Copy link

Hi, I am getting this same error when I were to click on a submit button multiple times. Is there an official fix to this at the moment?

vpavic added a commit to vpavic/spring-session that referenced this issue Oct 31, 2020
This commit provides JdbcIndexedSessionRepository customizers for the following databases:

- PostgreSQL
- Oracle
- MySQL (TODO)
- SQL Server (TODO)

These customizers are intended to address the concurrency issues occurring on insert of new session attribute by applying database specific SQL upsert/merge statement instead of a generic insert.

Closes: spring-projects#1213
@vpavic
Copy link
Contributor

vpavic commented Oct 31, 2020

I've just opened a WIP PR (see #1726) that intends to address this by providing a database specific SessionRepositoryCustomizer implementations that apply custom SQL upsert/merge statements.

Once we have that in place, the plan is to propose enhancement to Spring Boot's auto-configuration support for Spring Session so that the appropriate customizer gets registered automatically.

@vpavic vpavic changed the title When you fire multiple AJAX calls to a controller and call setAttribute you can get a ERROR: duplicate key value violates unique constraint "spring_session_attributes_pk" Concurrent requests adding the same session attribute result in duplicate key constraint violation Oct 31, 2020
@candrews
Copy link
Contributor

I've just opened a WIP PR (see #1726) that intends to address this by providing a database specific SessionRepositoryCustomizer implementations that apply custom SQL upsert/merge statements.

Once we have that in place, the plan is to propose enhancement to Spring Boot's auto-configuration support for Spring Session so that the appropriate customizer gets registered automatically.

Why not use #1481 which solves this problem without requiring changes in Spring Boot?

@vpavic
Copy link
Contributor

vpavic commented Oct 31, 2020

@candrews I believe we went at length about the reasons in the #1481 itself, but for completeness sake I'll only offer a brief recap here:

  • java.sql.Connection is too low-level concern for JdbcIndexedSessionRepository to deal with
  • reading JDBC metadata during JdbcIndexedSessionRepository initialization also isn't desirable (most likely we'll look into removing that from JdbcHttpSessionConfiguration as well)
  • adding complexity to JdbcIndexedSessionRepository that in some cases is untested isn't desirable

Spring Boot already detects which database you're working with, and acts on that information in several aspects of its auto-configuration facilities. With that in mind, it should be the best fitting place to (automatically) apply the configuration support we're looking to provide here.

Without Spring Boot, it will be just a matter of registering a single bean in order to opt into a more optimized, database specific SQL.

vpavic added a commit to vpavic/spring-session that referenced this issue Nov 1, 2020
This commit provides JdbcIndexedSessionRepository customizers for the following databases:

- PostgreSQL
- MySQL
- Oracle
- SQL Server (TODO)

These customizers are intended to address the concurrency issues occurring on insert of new session attribute by applying database specific SQL upsert/merge statement instead of a generic insert.

Closes: spring-projects#1213
vpavic added a commit to vpavic/spring-session that referenced this issue Nov 1, 2020
This commit provides JdbcIndexedSessionRepository customizers for the following databases:

- PostgreSQL
- MySQL
- Oracle
- SQL Server (TODO)

These customizers are intended to address the concurrency issues occurring on insert of new session attribute by applying database specific SQL upsert/merge statement instead of a generic insert.

Closes: spring-projects#1213
vpavic added a commit to vpavic/spring-session that referenced this issue Nov 1, 2020
This commit provides JdbcIndexedSessionRepository customizers for the following SQL dialects:

- Standard SQL (used by IBM DB2, Oracle, SQL Server)
- PostgreSQL
- MySQL (also used by MariaDB)

These customizers are intended to address the concurrency issues occurring on insert of new session attribute by applying SQL dialect specific SQL upsert/merge statement instead of a generic insert.

Closes: spring-projects#1213
vpavic added a commit to vpavic/spring-session that referenced this issue Nov 11, 2020
This commit provides JdbcIndexedSessionRepository customizers for the following SQL dialects:

- PostgreSQL
- MySQL (also used by MariaDB)
- SQL Server
- IBM DB2
- Oracle

These customizers are intended to address the concurrency issues occurring on insert of new session attribute by applying SQL dialect specific SQL upsert/merge statement instead of a generic insert.

Closes: spring-projects#1213
@vpavic vpavic added in: jdbc type: enhancement A general enhancement and removed status: waiting-for-triage An issue we've not yet triaged labels Nov 11, 2020
@vpavic vpavic added this to the 2.5.0-M1 milestone Nov 11, 2020
@vpavic vpavic closed this as completed in 0111c6e Nov 27, 2020
@inkassso
Copy link

I too encountered this issue on Spring Boot 2.7.12. A somewhat mysterious circumstance is that it only started manifesting itself after Spring WebFlux was integrated for streaming large data from a WebClient response directly to the HttpServletResponse's OutputStream.

Anyway, I looked up the Autoconfiguration code for Spring Session with JDBC and the specific customizers don't seem to have been picked up yet. Any idea when we can expect this?

Of course I can already use the appropriate Customizer in my own configuration, however, I think it would be helpful for other lost souls to know whether the issue can be fixed by just updating to a newer version of Spring Boot.

@hatbout
Copy link

hatbout commented Aug 12, 2023

If you are using MySQL, following steps below to fix the DuplicateKeyException: INSERT INTO SPRING_SESSION_ATTRIBUTES triggered by concurrent requests:

  1. bump spring-session-jdbc to 2.5.0-M1 or later: Provide database specific JdbcIndexedSessionRepository customizers #1726
  2. Enable MySqlJdbcIndexedSessionRepositoryCustomizer(): https://stackoverflow.com/a/69540376/16790354

Hope this tip helps.

@wiggle866
Copy link

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment