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

Add support for ACID transaction #49

Merged
merged 61 commits into from
Nov 21, 2024
Merged

Add support for ACID transaction #49

merged 61 commits into from
Nov 21, 2024

Conversation

tigerwill90
Copy link
Owner

@tigerwill90 tigerwill90 commented Nov 21, 2024

Introdcution

During a discussion with the team about foxmock, one of the questions was how we can ensure that a route with its mock can be registered in the router (those valid and non-conflicting with other routes) while not serving the registered handler before this mock is committed to the database. One of the solutions was to register a temporary handler such as "not found," and then only register the final handler after a database write succeeds. Another solution suggested by @hbenhoud was to add a "dry run" option to the router that fully validates the route but registers nothing. Both options are valid for inserting or updating a route, but they don’t scale really well if we have multiple inserts or updates to do in a single database transaction. This is also the case, to some extent, when deleting routes.

For this reason, this PR implement a new kind of immutable Radix Tree that supports lock-free read while allowing a single writer to make progress concurrently (like the actual one). Updates are applied by calculating the change which would be made to the tree were it mutable, assembling those changes into a patch which is propagated to the root and applied in a single atomic operation. The result is a shallow copy of the tree, where only the modified path and its ancestors are cloned, ensuring efficient updates and low memory overhead. Multiple patches can be applied in a single transaction, with intermediate nodes cached during the process to prevent redundant cloning.

This new design allow to supports read-write and read-only transactions (with Atomicity, Consistency, and Isolation; Durability is not supported as transactions are in memory). Thread that route requests always see a consistent version of the routing tree and are fully isolated from an ongoing transaction until committed. Read-only transactions capture a point-in-time snapshot of the tree, ensuring they do not observe any ongoing or committed changes made after their creation.

The new approach enhances read performance, especially under parallel workloads, by reducing atomic pointer operations during lookups. This comes at the cost of a small increase in write overhead due to broader tree mutations during updates.

Note that the unsafe Tree api has been entirely removed, this is a big breaking change for wizads.

Here a some exemple of the new API.

Managed read-write transaction

// Updates executes a function within the context of a read-write managed transaction. If no error is returned
// from the function then the transaction is committed. If an error is returned then the entire transaction is
// aborted.
if err := f.Updates(func(txn *fox.Txn) error {
	if _, err := txn.Handle(http.MethodGet, "exemple.com/hello/{name}", Handler); err != nil {
		return err
	}

	// Iter returns a collection of range iterators for traversing registered routes.
	it := txn.Iter()
	// When Iter() is called on a write transaction, it creates a point-in-time snapshot of the transaction state.
	// It means that writing on the current transaction while iterating is allowed, but the mutation will not be
	// observed in the result returned by Prefix (or any other iterator).
	for method, route := range it.Prefix(it.Methods(), "tmp.exemple.com/") {
		if err := f.Delete(method, route.Pattern()); err != nil {
			return err
		}
	}
	return nil
}); err != nil {
	log.Printf("transaction aborted: %s", err)
}

Unmanaged read-write transaction

// Txn create an unmanaged read-write or read-only transaction.
txn := f.Txn(true)
defer txn.Abort()

if _, err := txn.Handle(http.MethodGet, "exemple.com/hello/{name}", Handler); err != nil {
	log.Printf("error inserting route: %s", err)
	return
}

// Iter returns a collection of range iterators for traversing registered routes.
it := txn.Iter()
// When Iter() is called on a write transaction, it creates a point-in-time snapshot of the transaction state.
// It means that writing on the current transaction while iterating is allowed, but the mutation will not be
// observed in the result returned by Prefix (or any other iterator).
for method, route := range it.Prefix(it.Methods(), "tmp.exemple.com/") {
	if err := f.Delete(method, route.Pattern()); err != nil {
		log.Printf("error deleting route: %s", err)
		return
	}
}
// Finalize the transaction
txn.Commit()

Managed read-only transaction

_ = f.View(func(txn *fox.Txn) error {
	if txn.Has(http.MethodGet, "/foo") {
		if txn.Has(http.MethodGet, "/bar") {
			// do something
		}
	}
	return nil
})

Differential benchmark

goos: linux
goarch: amd64
pkg: github.com/tigerwill90/fox
cpu: Intel(R) Core(TM) i9-9900K CPU @ 3.60GHz
                    │   old.txt    │               new.txt               │
                    │    sec/op    │   sec/op     vs base                │
StaticAll-16          13.24µ ±  1%   13.23µ ± 1%        ~ (p=0.645 n=10)
GithubParamsAll-16    88.88n ±  3%   88.66n ± 1%        ~ (p=0.324 n=10)
OverlappingRoute-16   102.0n ±  1%   103.8n ± 1%   +1.81% (p=0.000 n=10)
StaticParallel-16     11.61n ±  1%   11.54n ± 1%   -0.60% (p=0.022 n=10)
CatchAll-16           39.83n ±  6%   39.82n ± 1%        ~ (p=1.000 n=10)
CatchAllParallel-16   6.277n ± 16%   5.192n ± 3%  -17.30% (p=0.000 n=10)
CloneWith-16          72.13n ±  2%   72.97n ± 2%   +1.16% (p=0.019 n=10)
geomean               82.09n         80.13n        -2.39%

- joinHostPath: efficient 0 allocation concat host & path
…handle url with domain. Special care must be taken to safely extract param value from the url.
Copy link

codecov bot commented Nov 21, 2024

Codecov Report

Attention: Patch coverage is 93.06541% with 88 lines in your changes missing coverage. Please review.

Project coverage is 91.50%. Comparing base (4b593ae) to head (defd22b).
Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
txn.go 70.44% 39 Missing and 8 partials ⚠️
internal/simplelru/list.go 75.80% 11 Missing and 4 partials ⚠️
fox.go 96.51% 5 Missing and 2 partials ⚠️
internal/simplelru/lru.go 94.33% 5 Missing and 1 partial ⚠️
tree.go 97.18% 3 Missing and 3 partials ⚠️
node.go 98.94% 3 Missing and 2 partials ⚠️
iter.go 87.50% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master      #49      +/-   ##
==========================================
- Coverage   91.88%   91.50%   -0.38%     
==========================================
  Files          19       22       +3     
  Lines        3131     3533     +402     
==========================================
+ Hits         2877     3233     +356     
- Misses        196      234      +38     
- Partials       58       66       +8     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.


🚨 Try these New Features:

@tigerwill90 tigerwill90 marked this pull request as ready for review November 21, 2024 20:37
@tigerwill90 tigerwill90 self-assigned this Nov 21, 2024
@tigerwill90 tigerwill90 merged commit 3f5ca66 into master Nov 21, 2024
8 checks passed
@tigerwill90 tigerwill90 deleted the feat/iradix branch November 21, 2024 22:51
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.

1 participant