Skip to content

fix(pay-slip)[backend]: resolve TOCTOU race condition in CreatePaySlip#45

Open
MohamadNazik wants to merge 1 commit intoLSFLK:mainfrom
MohamadNazik:fix/payslip-upsert-race-condition
Open

fix(pay-slip)[backend]: resolve TOCTOU race condition in CreatePaySlip#45
MohamadNazik wants to merge 1 commit intoLSFLK:mainfrom
MohamadNazik:fix/payslip-upsert-race-condition

Conversation

@MohamadNazik
Copy link
Contributor

Summary

This PR resolves a Time-of-Check-Time-of-Use (TOCTOU) race condition in the CreatePaySlip handler. The previous implementation performed an existence check followed by an insert or update in separate, non-atomic service calls, which allowed concurrent requests for the same user/month/year to result in duplicate records.

The Fix

  1. Database Layer: Exposed transaction support to allow atomic operations across multiple queries.
  2. Service Layer: Introduced a new UpsertPaySlip method that:
    • Wraps the existence check and the subsequent insert/update in a single database transaction.
    • Uses row-level locking (SELECT ... FOR UPDATE) to block concurrent requests for the same record until the transaction completes.
  3. Handler Layer: Simplified the logic by replacing the manual multi-step check with a single call to the atomic service method.

Verification

  • Confirmed transaction integrity and proper error handling/rollback logic.

@MohamadNazik MohamadNazik requested a review from a team March 11, 2026 08:34
@gemini-code-assist
Copy link

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses a critical Time-of-Check-Time-of-Use (TOCTOU) race condition in the CreatePaySlip functionality. By introducing robust transaction management and row-level locking at the database and service layers, it ensures that concurrent requests for the same pay slip record are handled atomically, preventing the creation of duplicate entries and maintaining data integrity. The changes streamline the handler logic while significantly enhancing the reliability of pay slip creation.

Highlights

  • Database Transaction Support: Added a Begin() method to the Database struct to expose transaction capabilities, enabling atomic operations.
  • Atomic Upsert Logic: Implemented a new UpsertPaySlip method in the service layer that wraps existence checks and insert/update operations within a single database transaction, utilizing SELECT ... FOR UPDATE for row-level locking to prevent race conditions.
  • Handler Simplification: Refactored the CreatePaySlip handler to replace the previous multi-step, non-atomic upsert logic with a single call to the new UpsertPaySlip service method, simplifying the code and ensuring atomicity.
Changelog
  • pay-slip-app/backend/internal/database/database.go
    • Added a Begin() method to the Database struct to initiate a new SQL transaction.
  • pay-slip-app/backend/internal/handlers/payslip_handlers.go
    • Removed the manual existence check and conditional insert/update logic from CreatePaySlip.
    • Replaced the manual upsert with a call to the new PaySlipService.UpsertPaySlip method.
  • pay-slip-app/backend/internal/services/payslip_service.go
    • Introduced UpsertPaySlip method to handle atomic creation or update of pay slips using database transactions and SELECT ... FOR UPDATE for concurrency control.
    • Implemented logic within UpsertPaySlip to either insert a new pay slip or update an existing one based on user_id, month, and year.
Activity
  • No human activity has been recorded on this pull request yet.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request effectively resolves the Time-of-Check-Time-of-Use (TOCTOU) race condition in CreatePaySlip by introducing an atomic upsert operation using a database transaction and row-level locking. However, a high-severity Denial of Service (DoS) vulnerability was identified in the database layer's transaction handling due to the use of a global mutex, which can lead to deadlocks and performance bottlenecks, especially during connection failures. Addressing this is crucial for application stability and scalability. Additionally, there are opportunities to enhance performance, maintain API consistency, and improve data integrity.

Comment on lines +118 to +122
func (db *Database) Begin() (*sql.Tx, error) {
db.mu.Lock()
defer db.mu.Unlock()
return db.Conn.Begin()
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The current implementation of Begin (and other database methods) acquires a global mutex db.mu and holds it for the entire duration of the database operation. This introduces a high-severity Denial of Service (DoS) vulnerability. If a database operation blocks, it can prevent the background pinger from reconnecting, leading to deadlocks and application unavailability. This also severely bottlenecks performance by serializing all database access, despite *sql.DB being thread-safe.

The mutex should only protect access to the db.Conn pointer, not the entire database operation. A sync.RWMutex could also be considered for finer-grained control, allowing concurrent reads while protecting writes during reconnection. The provided code suggestion demonstrates how to release the mutex before calling conn.Begin().

Suggested change
func (db *Database) Begin() (*sql.Tx, error) {
db.mu.Lock()
defer db.mu.Unlock()
return db.Conn.Begin()
}
func (db *Database) Begin() (*sql.Tx, error) {
db.mu.Lock()
conn := db.Conn
db.mu.Unlock()
return conn.Begin()
}

Comment on lines +90 to +96
result, err := h.PaySlipService.UpsertPaySlip(ps)
if err != nil {
http.Error(w, "Failed to save pay slip", http.StatusInternalServerError)
return
}

jsonResponse(w, http.StatusCreated, ps)
jsonResponse(w, http.StatusCreated, result)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The previous implementation returned http.StatusOK for updates and http.StatusCreated for new records. This change now always returns http.StatusCreated, which is a subtle change in the API's behavior that might affect clients.

To preserve the original behavior, I suggest modifying UpsertPaySlip to return a boolean indicating whether a new record was created. The handler can then use this to set the appropriate status code.

For example, if UpsertPaySlip is changed to return (*models.PaySlip, bool, error):

Suggested change
result, err := h.PaySlipService.UpsertPaySlip(ps)
if err != nil {
http.Error(w, "Failed to save pay slip", http.StatusInternalServerError)
return
}
jsonResponse(w, http.StatusCreated, ps)
jsonResponse(w, http.StatusCreated, result)
result, created, err := h.PaySlipService.UpsertPaySlip(ps)
if err != nil {
http.Error(w, "Failed to save pay slip", http.StatusInternalServerError)
return
}
statusCode := http.StatusOK
if created {
statusCode = http.StatusCreated
}
jsonResponse(w, statusCode, result)

Comment on lines +51 to +56
// 1. Lock existing row or potential slot using SELECT ... FOR UPDATE
// Note: In MySQL, if the record doesn't exist, this might not lock anything unless there's an index.
// However, we have an index on (user_id, month, year) [via idx_payslips_user_date, though it's partial or we should rely on unique constraint if it existed].
// Actually, the primary way to prevent duplicates is row-level locking on existing OR gap locking if applicable.
// A more robust way in MySQL without a UNIQUE constraint is to lock the user record or a mutex table,
// but SELECT ... FOR UPDATE on the specific criteria is the standard approach for "upsert-gate".

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

These comments correctly identify that SELECT ... FOR UPDATE is a standard approach to prevent race conditions. However, the most robust and performant solution to prevent duplicates is to enforce uniqueness at the database level.

I recommend adding a UNIQUE constraint on (user_id, month, year) in the pay_slips table via a new migration:

ALTER TABLE pay_slips ADD UNIQUE INDEX uq_payslips_user_date (user_id, month, year);

With this constraint in place, you could simplify this function significantly by using MySQL's INSERT ... ON DUPLICATE KEY UPDATE syntax. This is atomic and more efficient than a transaction with SELECT ... FOR UPDATE, and it would eliminate the need for manual transaction management for this operation.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(pay-slip): resolve TOCTOU race condition in CreatePaySlip using DB transaction

1 participant