Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
N
neoppod
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Issues
0
Issues
0
List
Boards
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Analytics
Analytics
CI / CD
Repository
Value Stream
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Xavier Thompson
neoppod
Commits
a4c06242
Commit
a4c06242
authored
Jan 10, 2017
by
Julien Muchembled
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Review aborting of transactions
parent
dc7a129c
Changes
12
Show whitespace changes
Inline
Side-by-side
Showing
12 changed files
with
182 additions
and
33 deletions
+182
-33
neo/client/app.py
neo/client/app.py
+10
-13
neo/lib/protocol.py
neo/lib/protocol.py
+2
-1
neo/master/app.py
neo/master/app.py
+1
-1
neo/master/handlers/client.py
neo/master/handlers/client.py
+24
-3
neo/master/transactions.py
neo/master/transactions.py
+3
-1
neo/storage/handlers/client.py
neo/storage/handlers/client.py
+0
-4
neo/storage/handlers/master.py
neo/storage/handlers/master.py
+4
-0
neo/storage/replicator.py
neo/storage/replicator.py
+11
-4
neo/storage/transactions.py
neo/storage/transactions.py
+2
-3
neo/tests/__init__.py
neo/tests/__init__.py
+25
-0
neo/tests/threaded/__init__.py
neo/tests/threaded/__init__.py
+7
-2
neo/tests/threaded/test.py
neo/tests/threaded/test.py
+93
-1
No files found.
neo/client/app.py
View file @
a4c06242
...
@@ -595,17 +595,14 @@ class Application(ThreadedApplication):
...
@@ -595,17 +595,14 @@ class Application(ThreadedApplication):
txn_context
=
self
.
_txn_container
.
pop
(
transaction
)
txn_context
=
self
.
_txn_container
.
pop
(
transaction
)
if
txn_context
is
None
:
if
txn_context
is
None
:
return
return
p
=
Packets
.
AbortTransaction
(
txn_context
[
'ttid'
])
# cancel transaction on all those nodes
conns
=
[
self
.
master_conn
]
for
uuid
in
txn_context
[
'involved_nodes'
]:
node
=
self
.
nm
.
getByUUID
(
uuid
)
if
node
is
not
None
:
conns
.
append
(
self
.
cp
.
getConnForNode
(
node
))
for
conn
in
conns
:
if
conn
is
not
None
:
try
:
try
:
conn
.
notify
(
p
)
notify
=
self
.
master_conn
.
notify
except
AttributeError
:
pass
else
:
try
:
notify
(
Packets
.
AbortTransaction
(
txn_context
[
'ttid'
],
txn_context
[
'involved_nodes'
]))
except
ConnectionClosed
:
except
ConnectionClosed
:
pass
pass
# We don't need to flush queue, as it won't be reused by future
# We don't need to flush queue, as it won't be reused by future
...
...
neo/lib/protocol.py
View file @
a4c06242
...
@@ -976,10 +976,11 @@ class StoreObject(Packet):
...
@@ -976,10 +976,11 @@ class StoreObject(Packet):
class
AbortTransaction
(
Packet
):
class
AbortTransaction
(
Packet
):
"""
"""
Abort a transaction. C ->
S, PM
.
Abort a transaction. C ->
PM -> S
.
"""
"""
_fmt
=
PStruct
(
'abort_transaction'
,
_fmt
=
PStruct
(
'abort_transaction'
,
PTID
(
'tid'
),
PTID
(
'tid'
),
PFUUIDList
,
# unused for PM -> S
)
)
class
StoreTransaction
(
Packet
):
class
StoreTransaction
(
Packet
):
...
...
neo/master/app.py
View file @
a4c06242
...
@@ -585,6 +585,6 @@ class Application(BaseApplication):
...
@@ -585,6 +585,6 @@ class Application(BaseApplication):
def
isStorageReady
(
self
,
uuid
):
def
isStorageReady
(
self
,
uuid
):
return
uuid
in
self
.
storage_ready_dict
return
uuid
in
self
.
storage_ready_dict
def
getStorageReadySet
(
self
,
readiness
):
def
getStorageReadySet
(
self
,
readiness
=
float
(
'inf'
)
):
return
{
k
for
k
,
v
in
self
.
storage_ready_dict
.
iteritems
()
return
{
k
for
k
,
v
in
self
.
storage_ready_dict
.
iteritems
()
if
v
<=
readiness
}
if
v
<=
readiness
}
neo/master/handlers/client.py
View file @
a4c06242
...
@@ -74,6 +74,12 @@ class ClientServiceHandler(MasterHandler):
...
@@ -74,6 +74,12 @@ class ClientServiceHandler(MasterHandler):
node
.
ask
(
p
,
timeout
=
60
)
node
.
ask
(
p
,
timeout
=
60
)
else
:
else
:
conn
.
answer
(
Errors
.
IncompleteTransaction
())
conn
.
answer
(
Errors
.
IncompleteTransaction
())
# It's simpler to abort automatically rather than asking the client
# to send a notification on tpc_abort, since it would have keep the
# transaction longer in list of transactions.
# This should happen so rarely that we don't try to minimize the
# number of abort notifications by looking the modified partitions.
self
.
abortTransaction
(
conn
,
ttid
,
app
.
getStorageReadySet
())
def
askFinalTID
(
self
,
conn
,
ttid
):
def
askFinalTID
(
self
,
conn
,
ttid
):
tm
=
self
.
app
.
tm
tm
=
self
.
app
.
tm
...
@@ -102,9 +108,24 @@ class ClientServiceHandler(MasterHandler):
...
@@ -102,9 +108,24 @@ class ClientServiceHandler(MasterHandler):
else
:
else
:
conn
.
answer
(
Packets
.
AnswerPack
(
False
))
conn
.
answer
(
Packets
.
AnswerPack
(
False
))
def
abortTransaction
(
self
,
conn
,
tid
):
def
abortTransaction
(
self
,
conn
,
tid
,
uuid_list
):
# BUG: The replicator may wait this transaction to be finished.
# Consider a failure when the connection between the storage and the
self
.
app
.
tm
.
abort
(
tid
,
conn
.
getUUID
())
# client breaks while the answer to the first write is sent back.
# In other words, the client can not know the exact set of nodes that
# know this transaction, and it sends us all nodes it considered for
# writing.
# We must also add those that are waiting for this transaction to be
# finished (returned by tm.abort), because they may have join the
# cluster after that the client started to abort.
app
=
self
.
app
involved
=
app
.
tm
.
abort
(
tid
,
conn
.
getUUID
())
involved
.
update
(
uuid_list
)
involved
.
intersection_update
(
app
.
getStorageReadySet
())
if
involved
:
p
=
Packets
.
AbortTransaction
(
tid
,
())
getByUUID
=
app
.
nm
.
getByUUID
for
involved
in
involved
:
getByUUID
(
involved
).
notify
(
p
)
# like ClientServiceHandler but read-only & only for tid <= backup_tid
# like ClientServiceHandler but read-only & only for tid <= backup_tid
...
...
neo/master/transactions.py
View file @
a4c06242
...
@@ -393,10 +393,12 @@ class TransactionManager(EventQueue):
...
@@ -393,10 +393,12 @@ class TransactionManager(EventQueue):
Abort a transaction
Abort a transaction
"""
"""
logging
.
debug
(
'Abort TXN %s for %s'
,
dump
(
ttid
),
uuid_str
(
uuid
))
logging
.
debug
(
'Abort TXN %s for %s'
,
dump
(
ttid
),
uuid_str
(
uuid
))
if
self
[
ttid
].
isPrepared
():
txn
=
self
[
ttid
]
if
txn
.
isPrepared
():
raise
ProtocolError
(
"commit already requested for ttid %s"
raise
ProtocolError
(
"commit already requested for ttid %s"
%
dump
(
ttid
))
%
dump
(
ttid
))
del
self
[
ttid
]
del
self
[
ttid
]
return
txn
.
_notification_set
def
lock
(
self
,
ttid
,
uuid
):
def
lock
(
self
,
ttid
,
uuid
):
"""
"""
...
...
neo/storage/handlers/client.py
View file @
a4c06242
...
@@ -57,9 +57,6 @@ class ClientOperationHandler(EventHandler):
...
@@ -57,9 +57,6 @@ class ClientOperationHandler(EventHandler):
compression
,
checksum
,
data
,
data_serial
)
compression
,
checksum
,
data
,
data_serial
)
conn
.
answer
(
p
)
conn
.
answer
(
p
)
def
abortTransaction
(
self
,
conn
,
ttid
):
self
.
app
.
tm
.
abort
(
ttid
)
def
askStoreTransaction
(
self
,
conn
,
ttid
,
*
txn_info
):
def
askStoreTransaction
(
self
,
conn
,
ttid
,
*
txn_info
):
self
.
app
.
tm
.
register
(
conn
,
ttid
)
self
.
app
.
tm
.
register
(
conn
,
ttid
)
self
.
app
.
tm
.
vote
(
ttid
,
txn_info
)
self
.
app
.
tm
.
vote
(
ttid
,
txn_info
)
...
@@ -201,7 +198,6 @@ class ClientReadOnlyOperationHandler(ClientOperationHandler):
...
@@ -201,7 +198,6 @@ class ClientReadOnlyOperationHandler(ClientOperationHandler):
conn
.
answer
(
Errors
.
ReadOnlyAccess
(
conn
.
answer
(
Errors
.
ReadOnlyAccess
(
'read-only access because cluster is in backuping mode'
))
'read-only access because cluster is in backuping mode'
))
abortTransaction
=
_readOnly
askStoreTransaction
=
_readOnly
askStoreTransaction
=
_readOnly
askVoteTransaction
=
_readOnly
askVoteTransaction
=
_readOnly
askStoreObject
=
_readOnly
askStoreObject
=
_readOnly
...
...
neo/storage/handlers/master.py
View file @
a4c06242
...
@@ -57,6 +57,10 @@ class MasterOperationHandler(BaseMasterHandler):
...
@@ -57,6 +57,10 @@ class MasterOperationHandler(BaseMasterHandler):
def
notifyUnlockInformation
(
self
,
conn
,
ttid
):
def
notifyUnlockInformation
(
self
,
conn
,
ttid
):
self
.
app
.
tm
.
unlock
(
ttid
)
self
.
app
.
tm
.
unlock
(
ttid
)
def
abortTransaction
(
self
,
conn
,
ttid
,
_
):
self
.
app
.
tm
.
abort
(
ttid
)
self
.
app
.
replicator
.
transactionFinished
(
ttid
)
def
askPack
(
self
,
conn
,
tid
):
def
askPack
(
self
,
conn
,
tid
):
app
=
self
.
app
app
=
self
.
app
logging
.
info
(
'Pack started, up to %s...'
,
dump
(
tid
))
logging
.
info
(
'Pack started, up to %s...'
,
dump
(
tid
))
...
...
neo/storage/replicator.py
View file @
a4c06242
...
@@ -135,13 +135,18 @@ class Replicator(object):
...
@@ -135,13 +135,18 @@ class Replicator(object):
self
.
replicate_dict
[
offset
]
=
max_tid
self
.
replicate_dict
[
offset
]
=
max_tid
self
.
_nextPartition
()
self
.
_nextPartition
()
def
transactionFinished
(
self
,
ttid
,
max_tid
):
def
transactionFinished
(
self
,
ttid
,
max_tid
=
None
):
""" Callback from MasterOperationHandler """
""" Callback from MasterOperationHandler """
try
:
self
.
ttid_set
.
remove
(
ttid
)
self
.
ttid_set
.
remove
(
ttid
)
except
KeyError
:
assert
max_tid
is
None
,
max_tid
return
min_ttid
=
min
(
self
.
ttid_set
)
if
self
.
ttid_set
else
INVALID_TID
min_ttid
=
min
(
self
.
ttid_set
)
if
self
.
ttid_set
else
INVALID_TID
for
offset
,
p
in
self
.
partition_dict
.
iteritems
():
for
offset
,
p
in
self
.
partition_dict
.
iteritems
():
if
p
.
max_ttid
and
p
.
max_ttid
<
min_ttid
:
if
p
.
max_ttid
and
p
.
max_ttid
<
min_ttid
:
p
.
max_ttid
=
None
p
.
max_ttid
=
None
if
max_tid
:
self
.
replicate_dict
[
offset
]
=
max_tid
self
.
replicate_dict
[
offset
]
=
max_tid
self
.
_nextPartition
()
self
.
_nextPartition
()
...
@@ -355,7 +360,9 @@ class Replicator(object):
...
@@ -355,7 +360,9 @@ class Replicator(object):
p
=
self
.
partition_dict
[
offset
]
p
=
self
.
partition_dict
[
offset
]
p
.
next_obj
=
add64
(
tid
,
1
)
p
.
next_obj
=
add64
(
tid
,
1
)
self
.
updateBackupTID
()
self
.
updateBackupTID
()
if
not
p
.
max_ttid
:
if
p
.
max_ttid
:
logging
.
debug
(
"unfinished transactions: %r"
,
self
.
ttid_set
)
else
:
self
.
app
.
tm
.
replicated
(
offset
,
tid
)
self
.
app
.
tm
.
replicated
(
offset
,
tid
)
logging
.
debug
(
"partition %u replicated up to %s from %r"
,
logging
.
debug
(
"partition %u replicated up to %s from %r"
,
offset
,
dump
(
tid
),
self
.
current_node
)
offset
,
dump
(
tid
),
self
.
current_node
)
...
...
neo/storage/transactions.py
View file @
a4c06242
...
@@ -324,9 +324,8 @@ class TransactionManager(EventQueue):
...
@@ -324,9 +324,8 @@ class TransactionManager(EventQueue):
Note: does not alter persistent content.
Note: does not alter persistent content.
"""
"""
if
ttid
not
in
self
.
_transaction_dict
:
if
ttid
not
in
self
.
_transaction_dict
:
# the tid may be unknown as the transaction is aborted on every node
assert
not
even_if_locked
# of the partition, even if no data was received (eg. conflict on
# See how the master processes AbortTransaction from the client.
# another node)
return
return
logging
.
debug
(
'Abort TXN %s'
,
dump
(
ttid
))
logging
.
debug
(
'Abort TXN %s'
,
dump
(
ttid
))
transaction
=
self
.
_transaction_dict
[
ttid
]
transaction
=
self
.
_transaction_dict
[
ttid
]
...
...
neo/tests/__init__.py
View file @
a4c06242
...
@@ -37,6 +37,7 @@ from time import time
...
@@ -37,6 +37,7 @@ from time import time
from
struct
import
pack
,
unpack
from
struct
import
pack
,
unpack
from
unittest.case
import
_ExpectedFailure
,
_UnexpectedSuccess
from
unittest.case
import
_ExpectedFailure
,
_UnexpectedSuccess
try
:
try
:
from
transaction.interfaces
import
IDataManager
from
ZODB.utils
import
newTid
from
ZODB.utils
import
newTid
except
ImportError
:
except
ImportError
:
pass
pass
...
@@ -378,6 +379,30 @@ class NeoUnitTestBase(NeoTestBase):
...
@@ -378,6 +379,30 @@ class NeoUnitTestBase(NeoTestBase):
return
packet
return
packet
class
TransactionalResource
(
object
):
class
_sortKey
(
object
):
def
__init__
(
self
,
last
):
self
.
_last
=
last
def
__cmp__
(
self
,
other
):
assert
type
(
self
)
is
not
type
(
other
),
other
return
1
if
self
.
_last
else
-
1
def
__init__
(
self
,
txn
,
last
,
**
kw
):
self
.
sortKey
=
lambda
:
self
.
_sortKey
(
last
)
for
k
in
kw
:
assert
callable
(
IDataManager
.
get
(
k
)),
k
self
.
__dict__
.
update
(
kw
)
txn
.
get
().
join
(
self
)
def
__getattr__
(
self
,
attr
):
if
callable
(
IDataManager
.
get
(
attr
)):
return
lambda
*
_
:
None
return
self
.
__getattribute__
(
attr
)
class
Patch
(
object
):
class
Patch
(
object
):
"""
"""
Patch attributes and revert later automatically.
Patch attributes and revert later automatically.
...
...
neo/tests/threaded/__init__.py
View file @
a4c06242
...
@@ -956,13 +956,12 @@ class NEOThreadedTest(NeoTestBase):
...
@@ -956,13 +956,12 @@ class NEOThreadedTest(NeoTestBase):
return
obj
return
obj
return
unpickler
return
unpickler
class
newThread
(
threading
.
Thread
):
class
new
Paused
Thread
(
threading
.
Thread
):
def
__init__
(
self
,
func
,
*
args
,
**
kw
):
def
__init__
(
self
,
func
,
*
args
,
**
kw
):
threading
.
Thread
.
__init__
(
self
)
threading
.
Thread
.
__init__
(
self
)
self
.
__target
=
func
,
args
,
kw
self
.
__target
=
func
,
args
,
kw
self
.
daemon
=
True
self
.
daemon
=
True
self
.
start
()
def
run
(
self
):
def
run
(
self
):
try
:
try
:
...
@@ -978,6 +977,12 @@ class NEOThreadedTest(NeoTestBase):
...
@@ -978,6 +977,12 @@ class NEOThreadedTest(NeoTestBase):
del
self
.
__exc_info
del
self
.
__exc_info
raise
etype
,
value
,
tb
raise
etype
,
value
,
tb
class
newThread
(
newPausedThread
):
def
__init__
(
self
,
*
args
,
**
kw
):
NEOThreadedTest
.
newPausedThread
.
__init__
(
self
,
*
args
,
**
kw
)
self
.
start
()
def
commitWithStorageFailure
(
self
,
client
,
txn
):
def
commitWithStorageFailure
(
self
,
client
,
txn
):
with
Patch
(
client
,
_getFinalTID
=
lambda
*
_
:
None
):
with
Patch
(
client
,
_getFinalTID
=
lambda
*
_
:
None
):
self
.
assertRaises
(
ConnectionClosed
,
txn
.
commit
)
self
.
assertRaises
(
ConnectionClosed
,
txn
.
commit
)
...
...
neo/tests/threaded/test.py
View file @
a4c06242
...
@@ -32,7 +32,7 @@ from neo.lib.connection import ServerConnection, MTClientConnection
...
@@ -32,7 +32,7 @@ from neo.lib.connection import ServerConnection, MTClientConnection
from
neo.lib.exception
import
DatabaseFailure
,
StoppedOperation
from
neo.lib.exception
import
DatabaseFailure
,
StoppedOperation
from
neo.lib.protocol
import
CellStates
,
ClusterStates
,
NodeStates
,
Packets
,
\
from
neo.lib.protocol
import
CellStates
,
ClusterStates
,
NodeStates
,
Packets
,
\
ZERO_OID
,
ZERO_TID
ZERO_OID
,
ZERO_TID
from
..
import
expectedFailure
,
Patch
from
..
import
expectedFailure
,
Patch
,
TransactionalResource
from
.
import
ConnectionFilter
,
LockLock
,
NEOThreadedTest
,
with_cluster
from
.
import
ConnectionFilter
,
LockLock
,
NEOThreadedTest
,
with_cluster
from
neo.lib.util
import
add64
,
makeChecksum
,
p64
,
u64
from
neo.lib.util
import
add64
,
makeChecksum
,
p64
,
u64
from
neo.client.exception
import
NEOPrimaryMasterLost
,
NEOStorageError
from
neo.client.exception
import
NEOPrimaryMasterLost
,
NEOStorageError
...
@@ -1463,5 +1463,97 @@ class Test(NEOThreadedTest):
...
@@ -1463,5 +1463,97 @@ class Test(NEOThreadedTest):
value_list
.
append
(
r
[
x
].
value
)
value_list
.
append
(
r
[
x
].
value
)
self
.
assertEqual
(
value_list
,
range
(
3
))
self
.
assertEqual
(
value_list
,
range
(
3
))
@
with_cluster
(
replicas
=
1
,
partitions
=
3
,
storage_count
=
3
)
def
testMasterArbitratingVote
(
self
,
cluster
):
"""
p
\
S
1
2 3
0 U U .
1 . U U
2 U . U
With the above setup, check when a client C1 fails to connect to S2
and another C2 fails to connect to S1.
For the first 2 scenarios:
- C1 first votes (the master accepts)
- C2 vote is delayed until C1 decides to finish or abort
"""
def
delayAbort
(
conn
,
packet
):
return
isinstance
(
packet
,
Packets
.
AbortTransaction
)
def
noConnection
(
jar
,
storage
):
return
Patch
(
jar
.
db
().
storage
.
app
.
cp
,
getConnForNode
=
lambda
orig
,
node
:
None
if
node
.
getUUID
()
==
storage
.
uuid
else
orig
(
node
))
def
c1_vote
(
txn
):
def
vote
(
orig
,
*
args
):
result
=
orig
(
*
args
)
ll
()
return
result
with
LockLock
()
as
ll
,
Patch
(
cluster
.
master
.
tm
,
vote
=
vote
):
commit2
.
start
()
ll
()
if
c1_aborts
:
raise
Exception
pt
=
[{
x
.
getUUID
()
for
x
in
x
}
for
x
in
cluster
.
master
.
pt
.
partition_list
]
cluster
.
storage_list
.
sort
(
key
=
lambda
x
:
(
x
.
uuid
not
in
pt
[
0
],
x
.
uuid
in
pt
[
1
]))
pt
=
'UU.|.UU|U.U'
self
.
assertPartitionTable
(
cluster
,
pt
)
s1
,
s2
,
s3
=
cluster
.
storage_list
t1
,
c1
=
cluster
.
getTransaction
()
with
cluster
.
newClient
(
1
)
as
db
:
t2
,
c2
=
cluster
.
getTransaction
(
db
)
with
noConnection
(
c1
,
s2
),
noConnection
(
c2
,
s1
):
cluster
.
client
.
cp
.
connection_dict
[
s2
.
uuid
].
close
()
self
.
tic
()
for
c1_aborts
in
0
,
1
:
# 0: C1 finishes, C2 vote fails
# 1: C1 aborts, C2 finishes
#
# Although we try to modify the same oid, there's no
# conflict because each storage node sees a single
# and different transaction: vote to storages is done
# in parallel, and the master must be involved as an
# arbitrator, which ultimately rejects 1 of the 2
# transactions, preferably before the second phase of
# the commit.
t1
.
begin
();
c1
.
root
().
_p_changed
=
1
t2
.
begin
();
c2
.
root
().
_p_changed
=
1
commit2
=
self
.
newPausedThread
(
t2
.
commit
)
TransactionalResource
(
t1
,
1
,
tpc_vote
=
c1_vote
)
with
ConnectionFilter
()
as
f
:
if
not
c1_aborts
:
f
.
add
(
delayAbort
)
f
.
delayAskFetchTransactions
(
lambda
_
:
f
.
discard
(
delayAbort
))
try
:
t1
.
commit
()
self
.
assertFalse
(
c1_aborts
)
except
Exception
:
self
.
assertTrue
(
c1_aborts
)
try
:
commit2
.
join
()
self
.
assertTrue
(
c1_aborts
)
except
NEOStorageError
:
self
.
assertFalse
(
c1_aborts
)
self
.
tic
()
self
.
assertPartitionTable
(
cluster
,
'OU.|.UU|O.U'
if
c1_aborts
else
'UO.|.OU|U.U'
)
self
.
tic
()
self
.
assertPartitionTable
(
cluster
,
pt
)
# S3 fails while C1 starts to finish
with
ConnectionFilter
()
as
f
:
f
.
add
(
lambda
conn
,
packet
:
conn
.
getUUID
()
==
s3
.
uuid
and
isinstance
(
packet
,
Packets
.
AcceptIdentification
))
t1
.
begin
();
c1
.
root
().
_p_changed
=
1
TransactionalResource
(
t1
,
0
,
tpc_finish
=
lambda
*
_
:
cluster
.
master
.
nm
.
getByUUID
(
s3
.
uuid
)
.
getConnection
().
close
())
self
.
assertRaises
(
NEOStorageError
,
t1
.
commit
)
self
.
assertPartitionTable
(
cluster
,
'UU.|.UO|U.O'
)
self
.
tic
()
self
.
assertPartitionTable
(
cluster
,
pt
)
if
__name__
==
"__main__"
:
if
__name__
==
"__main__"
:
unittest
.
main
()
unittest
.
main
()
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment